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:
co-authored by
Claude Opus 5
parent
6f6b287d7e
commit
8ad91a6c28
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user