Rozdeleni na sluzby a konektory, pristupove udaje do konektoru

Slovo "konektor" v kodu znamenalo katalog toho, co umime. Ted znamena napojeni
jedne firmy, tedy to, co tim mysli i uzivatel. Popis modelu je
v documentation/12-sluzby-a-konektory.md.

Tri vrstvy:
- Sluzba: ze iDoklad existuje, co umi a co potrebuje k napojeni. Nase.
- Skript: kod, ktery jednu operaci sluzby opravdu vykona. Nas.
- Konektor: ucet firmy vcetne jejich pristupovych udaju. Firemni.

Pristupove udaje se prestaly cist z environment variables. Cela instance by
mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni
je vlastnost firmy, ne prostredi. Z prostredi zustava jen SERVICES_BASE_URL.

Pridano:
- src/data/services.ts: sluzba nese general, appId, visibility, credentials
  a verifyPath. Kategorie "obecne" sdruzuje veci, ktere ma kazdy a nepotrebuji
  konektor: webhook, planovac, tickety, transformace dat, HTTP pozadavek,
  pauza, zapis do logu
- viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce
  platformy. Neviditelna sluzba se z API nevraci vubec, ne se stavem 403 -
  firma nema poznat, ze takova sluzba existuje
- src/data/connectorStore.ts: konektory za firmu vcetne hodnot udaju. Hodnoty
  se z API nikdy nevraci, jen filled a missing. Prazdne pole hodnotu nemeni,
  takze ulozeni formularu bez tajnych hodnot nic nepresepe
- FlowStep.connectorId: krok rika, pod kterym napojenim volat. null = vychozi
  konektor firmy, diky tomu je vzorovy strom prenositelny mezi firmami
- overeni konektoru pres verifyPath, tedy cteci volani vyzadujici autorizaci.
  U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas, jinak by
  zeleny vysledek uzivateli lhal
- stranky /dashboard/sluzby a /dashboard/konektory vcetne formularu udaju
- endpointy /api/dashboard/services a CRUD /api/dashboard/connectors ve Swaggeru
- predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login
  strance, kvuli testovani prototypu

Zmeneno:
- stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy.
  Sluzba ma jen available nebo planned
- validace stromu overuje i konektor. Cizi konektor je chyba, chybejici
  napojeni nedodelek - rozdelana prace se nezahazuje
- prejmenovani napric kodem: Connector na Service, FlowStep.connectorId na
  serviceId, GET /connectors na GET /services, connectorIcons na serviceIcons,
  stranka Konektory (katalog) na Sluzby. Prevodni tabulka je v dokumentu 12

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 14:11:50 +02:00
co-authored by Claude Opus 5
parent 6f6b287d7e
commit 8ad91a6c28
37 changed files with 3006 additions and 776 deletions
+316
View File
@@ -0,0 +1,316 @@
/**
* Sluzby a konektory.
*
* Sluzba je to, co umime. Konektor je napojeni jedne firmy na jednu sluzbu
* vcetne jejich pristupovych udaju. Rozdeleni je popsane
* v documentation/12-sluzby-a-konektory.md.
*
* Dve pravidla, ktera se tady nesmi porusit:
* - **hodnoty pristupovych udaju se nikdy nevraci**, jen jmena vyplnenych poli,
* - **sluzba, kterou uzivatel nevidi, se nevraci vubec**, ne se stavem
* "nemate pravo". Firma nema z odpovedi poznat, ze takova sluzba existuje.
*
* Volani cizich sluzeb dela vzdy server. Z prohlizece by to neproslo ani
* technicky (sluzby kontroluji IP) a hlavne by to znamenalo poslat pristupove
* udaje do prohlizece.
*/
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import {
connectorCountsByService,
createConnector,
deleteConnector,
getConnector,
listConnectors,
setConnectorStatus,
toPublicConnector,
updateConnector,
validateConnectorValues,
} from '../data/connectorStore.js';
import {
canSeeService,
findService,
serviceCatalog,
serviceCategories,
visibleServices,
type Service,
} from '../data/services.js';
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe } from '../scripts/util.js';
export const connectorsRouter = Router();
/**
* Firma, do ktere se zapisuje. Konektor je vzdy jedne firmy, i kdyz uzivatel
* kouka na pohled "vse" - jinak by nebylo kam ho zaloz1t.
*/
function tenantOrDeny(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[connectors] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
/** Sluzba, kterou uzivatel vidi. Neviditelna se chova jako neexistujici. */
function serviceOrDeny(
req: Request,
res: Response,
serviceId: string,
tenantId: string,
): Service | null {
const service = findService(serviceId);
if (!service || !canSeeService(service, req.user!, tenantId)) {
console.warn(`[connectors] ${req.user!.email}: pokus o sluzbu ${serviceId} bez pristupu`);
res.status(404).json({ error: 'not_found', message: 'Služba neexistuje.' });
return null;
}
return service;
}
// -------------------------------------------------------------------- sluzby
/**
* Katalog sluzeb za firmu.
*
* `connectorCount` je to, co dela z katalogu neco uzitecneho: podle nej klient
* pozna rozdil mezi "napojeno" a "muzete si napojit". Neni to vlastnost sluzby,
* ale te firmy, proto se to pocita tady a ne v katalogu.
*/
connectorsRouter.get('/services', (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
const visible = visibleServices(req.user!, tenantId);
const withScripts = new Map(serviceCatalog().map((service) => [service.id, service]));
const counts = tenantId ? connectorCountsByService([tenantId]) : new Map<string, number>();
const items = visible.map((service) => {
const merged = withScripts.get(service.id) ?? service;
return {
...merged,
/** Vychozi adresa, aby uzivatel videl, kam to vlastne pujde. */
baseUrl: serviceBaseUrl(merged),
connectorCount: counts.get(service.id) ?? 0,
/** Kolik operaci uz opravdu neco dela, tedy ma skript. */
implementedActions: merged.actions.filter((action) => action.implementation === 'script')
.length,
};
});
res.json({ categories: serviceCategories, items, tenantId: tenantId ?? null });
});
// ----------------------------------------------------------------- konektory
connectorsRouter.get('/', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const serviceId = typeof req.query.serviceId === 'string' ? req.query.serviceId : undefined;
const items = listConnectors([tenantId], serviceId ? { serviceId } : {})
// Konektor na sluzbu, kterou uzivatel nevidi, mu taky ukazovat nebudeme.
.filter((connector) => {
const service = findService(connector.serviceId);
return service !== undefined && canSeeService(service, req.user!, tenantId);
})
.map(toPublicConnector);
return res.json({ items, tenantId });
});
connectorsRouter.get('/:id', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
if (!serviceOrDeny(req, res, connector.serviceId, tenantId)) return;
return res.json(toPublicConnector(connector));
});
const createSchema = z.object({
serviceId: z.string().min(1),
name: z.string().trim().min(1, 'Konektor musí mít název.').max(80),
baseUrl: z.string().trim().url('Adresa musí být platná URL.').nullable().optional(),
values: z.record(z.string()).optional(),
});
connectorsRouter.post('/', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
const service = serviceOrDeny(req, res, parsed.data.serviceId, tenantId);
if (!service) return;
if (service.general) {
return res.status(400).json({
error: 'validation_error',
message: `${service.name} je obecná služba, konektor nepotřebuje.`,
});
}
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 connector = createConnector({
tenantId,
serviceId: service.id,
name: parsed.data.name,
baseUrl: parsed.data.baseUrl ?? null,
values: parsed.data.values,
});
return res.status(201).json(toPublicConnector(connector));
});
const updateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
baseUrl: z.string().trim().url('Adresa musí být platná URL.').nullable().optional(),
/** Prazdny retezec hodnotu smaze, chybejici klic ji nechá. */
values: z.record(z.string()).optional(),
enabled: z.boolean().optional(),
isDefault: z.literal(true).optional(),
});
connectorsRouter.patch('/:id', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const existing = getConnector(req.params.id, [tenantId]);
if (!existing) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
const service = serviceOrDeny(req, res, existing.serviceId, tenantId);
if (!service) return;
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (parsed.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 updated = updateConnector(req.params.id, parsed.data, [tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
return res.json(toPublicConnector(updated));
});
connectorsRouter.delete('/:id', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!deleteConnector(req.params.id, [tenantId])) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
return res.status(204).end();
});
// -------------------------------------------------------------------- overeni
/**
* Overeni konektoru.
*
* Vola `verifyPath` sluzby, coz je zamerne **cteci** volani, ktere vyzaduje
* autorizaci. Kdyz ho sluzba nema, overi se jen `/health` - a odpoved to rekne
* nahlas, aby si nikdo nemyslel, ze jsou overene i pristupove udaje.
*/
connectorsRouter.post('/:id/test', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
const service = serviceOrDeny(req, res, connector.serviceId, tenantId);
if (!service) return;
const target = resolveTarget(service.id, connector);
if (!target.ready) {
setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]);
return res.json({
ok: false,
checked: 'nic',
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
});
}
const path = service.verifyPath ?? '/health';
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
const redact = createRedactor(Object.values(target.headers));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const http = createHttp({
target,
signal: controller.signal,
idempotencyKey: `verify:${connector.id}`,
redact,
log: (message) => console.info(`[connectors] test ${connector.id}: ${message}`),
onCall: () => undefined,
});
const response = await http.get(path);
setConnectorStatus(connector.id, 'ok', null, [tenantId]);
return res.json({
ok: true,
checked,
status: response.status,
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í.',
});
} catch (err) {
const message =
err instanceof ScriptError ? redact(err.message) : redact(describe(err, 200));
setConnectorStatus(connector.id, 'error', message, [tenantId]);
console.warn(`[connectors] test ${connector.id} selhal: ${message}`);
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
return res.json({ ok: false, checked, message });
} finally {
clearTimeout(timer);
}
});
+83 -24
View File
@@ -18,11 +18,17 @@ import {
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import {
connectorCatalog,
connectorCategories,
defaultConnectorFor,
getConnector,
} from '../data/connectorStore.js';
import {
findOperation,
findService,
providedFieldsFor,
} from '../data/connectors.js';
serviceCatalog,
serviceCategories,
visibleServices,
} from '../data/services.js';
import {
getLayout,
hasCustomLayout,
@@ -47,6 +53,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { connectorsRouter } from './connectors.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
@@ -345,17 +352,30 @@ dashboardRouter.use('/stream', streamRouter);
// Skripty konektoru. Taky pred obecnymi cestami.
dashboardRouter.use('/scripts', scriptsRouter);
// ---------------------------------------------------------------- konektory
// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru.
dashboardRouter.use('/connectors', connectorsRouter);
// ------------------------------------------------------------------- sluzby
/**
* Katalog uz neni jen staticky seznam. Operace, ktere obsluhuje skript, se
* domeruji z jeho manifestu, takze builder vidi skutecne vstupy a vystupy.
* Podrobnosti v `src/scripts/catalog.ts`.
* 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('/connectors', (_req, res) => {
dashboardRouter.get('/services', (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id));
res.json({
categories: connectorCategories,
items: connectorCatalog(),
categories: serviceCategories,
items: serviceCatalog().filter((service) => visible.has(service.id)),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
@@ -374,8 +394,10 @@ const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
z.object({
id: z.string().min(1),
kind: z.literal('action'),
connectorId: z.string().min(1),
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(),
}),
@@ -419,7 +441,7 @@ const fieldSchema = z.object({
const flowSchema = z.object({
trigger: z
.object({
connectorId: z.string().min(1),
serviceId: z.string().min(1),
operationId: z.string().min(1),
fields: z.array(fieldSchema),
// Token generuje server. Cokoliv od klienta se ignoruje.
@@ -447,25 +469,33 @@ const updateSchema = z.object({
function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeof flowSchema> {
if (!flow.trigger) return flow;
const provided = providedFieldsFor(flow.trigger.connectorId, flow.trigger.operationId);
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 konektor a operaci.
* 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.
*/
function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
function validateFlowReferences(
flow: z.infer<typeof flowSchema>,
tenantIds: string[],
): { problems: string[]; issues: string[] } {
const problems: string[] = [];
/** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */
const issues: string[] = [];
if (!flow.trigger) return problems;
if (!flow.trigger) return { problems, issues };
const trigger = findOperation(flow.trigger.connectorId, flow.trigger.operationId, 'trigger');
const trigger = findOperation(flow.trigger.serviceId, flow.trigger.operationId, 'trigger');
if (!trigger) {
problems.push(
`Spouštěč ${flow.trigger.connectorId}/${flow.trigger.operationId} neexistuje v katalogu.`,
`Spouštěč ${flow.trigger.serviceId}/${flow.trigger.operationId} neexistuje v katalogu.`,
);
}
@@ -497,9 +527,9 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
walk(step.no);
continue;
}
const action = findOperation(step.connectorId, step.operationId, 'action');
const action = findOperation(step.serviceId, step.operationId, 'action');
if (!action) {
problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`);
problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`);
continue;
}
@@ -509,15 +539,39 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
for (const key of Object.keys(step.inputs ?? {})) {
if (!allowed.has(key)) {
problems.push(
`Akce ${step.connectorId}/${step.operationId} nemá nastavitelné pole „${key}".`,
`Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole „${key}".`,
);
}
}
// Vybrany konektor musi patrit te same firme a te same sluzbe.
// Cizi konektor je rozbity strom, ne nedodelek.
if (step.connectorId) {
const connector = 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 = defaultConnectorFor(tenantIds[0], step.serviceId);
if (!fallback) {
issues.push(
`Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`,
);
}
}
}
}
};
walk(flow.steps);
return problems;
return { problems, issues };
}
dashboardRouter.get('/automations', (req, res) => {
@@ -569,8 +623,10 @@ dashboardRouter.put('/automations/:id', (req, res) => {
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
let connectorIssues: string[] = [];
if (flow) {
const problems = validateFlowReferences(flow);
const { problems, issues } = validateFlowReferences(flow, writableTenants(req));
if (problems.length > 0) {
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
return res.status(400).json({
@@ -579,13 +635,16 @@ dashboardRouter.put('/automations/:id', (req, res) => {
issues: problems.map((message) => ({ path: 'flow', message })),
});
}
connectorIssues = issues;
}
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
return res.json(updated);
// 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. */
+86 -13
View File
@@ -9,9 +9,11 @@
import { Router } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import { defaultConnectorFor, getConnector, toPublicConnector } from '../data/connectorStore.js';
import { findService } from '../data/services.js';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
import { operationIdOf, serviceIdOf } from '../scripts/manifest.js';
import {
ensureLoaded,
getScript,
@@ -29,20 +31,39 @@ export const scriptsRouter = Router();
/** Manifest plus to, co si klient nema dopocitavat sam. */
async function scriptSummaries() {
const manifests = await listManifests();
return manifests.map((manifest) => ({
...manifest,
connectorId: connectorIdOf(manifest.id),
operationId: operationIdOf(manifest.id),
}));
return manifests.map((manifest) => {
const serviceId = serviceIdOf(manifest.id);
return {
...manifest,
serviceId,
serviceName: findService(serviceId)?.name ?? serviceId,
operationId: operationIdOf(manifest.id),
};
});
}
scriptsRouter.get('/', async (_req, res) => {
scriptsRouter.get('/', async (req, res) => {
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
// Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat.
const connectors = Object.fromEntries(
items
.map((item) => {
const connector = tenantId ? defaultConnectorFor(tenantId, item.serviceId) : undefined;
return connector ? [item.serviceId, toPublicConnector(connector)] : null;
})
.filter((entry): entry is [string, ReturnType<typeof toPublicConnector>] => entry !== null),
);
res.json({
items,
problems,
connections: connectorsWithAuth().map(connectionStatus),
connectors,
tenantId: tenantId ?? null,
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
directory: scriptsDir(),
});
@@ -67,14 +88,23 @@ scriptsRouter.get('/:id', async (req, res) => {
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
const problems = await scriptProblems();
const serviceId = serviceIdOf(id);
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
const connector = tenantId ? defaultConnectorFor(tenantId, serviceId) : undefined;
return res.json({
id,
connectorId: connectorIdOf(id),
serviceId,
serviceName: findService(serviceId)?.name ?? serviceId,
operationId: operationIdOf(id),
manifest: script?.manifest ?? null,
code: source,
problem: problems.find((problem) => problem.scriptId === id) ?? null,
connection: connectionStatus(connectorIdOf(id)),
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
connector: connector ? toPublicConnector(connector) : null,
});
});
@@ -87,7 +117,7 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
if (!isValidScriptId(id)) {
return res.status(400).json({
error: 'validation_error',
message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
message: 'Neplatné ID skriptu. Povolený tvar je sluzba.operace.',
});
}
@@ -114,6 +144,8 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
const testSchema = z.object({
inputs: z.record(z.unknown()).default({}),
/** Pod jakym konektorem se to zavola. Chybi = vychozi konektor firmy. */
connectorId: z.string().min(1).optional(),
});
/**
@@ -137,7 +169,48 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
});
}
const result = await runScript(id, parsed.data.inputs);
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
if (!tenantId) {
return res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
}
const serviceId = serviceIdOf(id);
const service = findService(serviceId);
// Konektor musi patrit te same firme. Cizi se chova jako neexistujici.
const connector = parsed.data.connectorId
? getConnector(parsed.data.connectorId, [tenantId])
: defaultConnectorFor(tenantId, serviceId);
if (parsed.data.connectorId && !connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
if (connector && connector.serviceId !== serviceId) {
return res.status(400).json({
error: 'validation_error',
message: 'Vybraný konektor patří jiné službě.',
});
}
if (!connector && service && !service.general) {
return res.json({
ok: false,
scriptId: id,
outputs: {},
logs: [],
durationMs: 0,
httpCalls: 0,
error: {
kind: 'config',
message: `Služba ${service.name} potřebuje konektor. Vytvořte ho v Konektorech.`,
retryable: false,
},
});
}
const result = await runScript(id, parsed.data.inputs, { connector: connector ?? null });
console.info(`[scripts] test ${id} uzivatelem ${req.user!.email}: ${result.ok ? 'ok' : 'chyba'}`);
// Chyba skriptu neni chyba API. Vysledek se vraci vzdy s 200 vcetne popisu.
+16 -16
View File
@@ -70,7 +70,7 @@ const schema = z.discriminatedUnion('action', [
const channelPresets: Record<
TicketChannel,
{
connectorId: string;
serviceId: string;
operationId: string;
triggerLabel: string;
subjects: string[];
@@ -83,7 +83,7 @@ const channelPresets: Record<
}
> = {
whatsapp: {
connectorId: 'whatsapp',
serviceId: 'whatsapp',
operationId: 'message-received',
triggerLabel: 'Přijata zpráva z WhatsApp',
subjects: [
@@ -98,7 +98,7 @@ const channelPresets: Record<
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
},
facebook: {
connectorId: 'facebook',
serviceId: 'facebook',
operationId: 'message-received',
triggerLabel: 'Přijata zpráva z Facebook Messengeru',
subjects: [
@@ -113,7 +113,7 @@ const channelPresets: Record<
`{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`,
},
instagram: {
connectorId: 'instagram',
serviceId: 'instagram',
operationId: 'message-received',
triggerLabel: 'Přijata přímá zpráva na Instagramu',
subjects: [
@@ -128,7 +128,7 @@ const channelPresets: Record<
`{ "username": "${contact}", "text": "${subject}" }`,
},
email: {
connectorId: 'email',
serviceId: 'email',
operationId: 'received',
triggerLabel: 'Přijat e-mail do schránky podpora@',
subjects: [
@@ -143,7 +143,7 @@ const channelPresets: Record<
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
},
voice: {
connectorId: 'voicebot',
serviceId: 'voicebot',
operationId: 'call-received',
triggerLabel: 'Příchozí hovor na linku 800 100 200',
subjects: [
@@ -158,7 +158,7 @@ const channelPresets: Record<
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
},
form: {
connectorId: 'form',
serviceId: 'form',
operationId: 'submitted',
triggerLabel: 'Odeslán formulář Požadavek na úpravu',
subjects: [
@@ -173,7 +173,7 @@ const channelPresets: Record<
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
},
portal: {
connectorId: 'ticket',
serviceId: 'ticket',
operationId: 'created',
triggerLabel: 'Založeno ručně z portálu',
subjects: [
@@ -227,7 +227,7 @@ function buildIntakeTrace(input: {
const trace: TraceInput[] = [
{
kind: 'trigger',
connectorId: preset.connectorId,
serviceId: preset.serviceId,
operationId: preset.operationId,
label: preset.triggerLabel,
status: 'ok',
@@ -236,7 +236,7 @@ function buildIntakeTrace(input: {
},
{
kind: 'action',
connectorId: 'ai-text',
serviceId: 'ai-text',
operationId: 'classify',
label: 'Zařazení do kategorie',
status: 'ok',
@@ -245,7 +245,7 @@ function buildIntakeTrace(input: {
},
{
kind: 'action',
connectorId: 'raynet',
serviceId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy v CRM',
status: input.known ? 'ok' : 'error',
@@ -265,7 +265,7 @@ function buildIntakeTrace(input: {
children: [
{
kind: 'action',
connectorId: 'ticket',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
@@ -274,7 +274,7 @@ function buildIntakeTrace(input: {
},
{
kind: 'action',
connectorId: 'ticket',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele podle kanálu',
status: 'ok',
@@ -294,7 +294,7 @@ function buildIntakeTrace(input: {
children: [
{
kind: 'action',
connectorId: 'ticket',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu bez napojení na firmu',
status: 'ok',
@@ -303,7 +303,7 @@ function buildIntakeTrace(input: {
},
{
kind: 'action',
connectorId: 'raynet',
serviceId: 'raynet',
operationId: 'create-lead',
label: 'Založení obchodního případu k dohledání',
status: 'ok',
@@ -312,7 +312,7 @@ function buildIntakeTrace(input: {
},
{
kind: 'action',
connectorId: 'ticket',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele',
status: 'skipped',