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
+67
-57
@@ -9,7 +9,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
|
||||
import { actionInputsFor, findConnector } from './connectors.js';
|
||||
import { actionInputsFor, findService } from './services.js';
|
||||
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
|
||||
import { referencedFields } from './templates.js';
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface TriggerField {
|
||||
}
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
|
||||
fields: TriggerField[];
|
||||
@@ -37,15 +37,25 @@ export interface FlowTrigger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Krok stromu. `action` je jeden ukon nad konektorem, `condition` rozdeluje
|
||||
* Krok stromu. `action` je jeden ukon nad sluzbou, `condition` rozdeluje
|
||||
* beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom.
|
||||
*/
|
||||
export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
/** Ktera sluzba a jeji operace. Sluzba je to, co umime. */
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/**
|
||||
* Ktery konektor firmy se pouzije, tedy pod jakymi udaji se to zavola.
|
||||
*
|
||||
* `null` znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je
|
||||
* vzorovy strom prenositelny mezi firmami - kazde se dosadi jeji vlastni
|
||||
* napojeni. U obecnych sluzeb (webhook, pauza, transformace) je vzdy null,
|
||||
* ty konektor nepotrebuji.
|
||||
*/
|
||||
connectorId?: string | null;
|
||||
/**
|
||||
* Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je
|
||||
* sablona s odkazy na parametry spoustece (`{{subject}}`).
|
||||
@@ -145,11 +155,11 @@ function actionInputIssues(
|
||||
available: TriggerField[],
|
||||
): string[] {
|
||||
const knownNames = new Set(available.map((field) => field.name));
|
||||
const catalog = actionInputsFor(step.connectorId, step.operationId);
|
||||
const catalog = actionInputsFor(step.serviceId, step.operationId);
|
||||
if (catalog.length === 0) return [];
|
||||
|
||||
const issues: string[] = [];
|
||||
const operationName = findConnector(step.connectorId)?.name ?? step.connectorId;
|
||||
const operationName = findService(step.serviceId)?.name ?? step.serviceId;
|
||||
|
||||
for (const field of catalog) {
|
||||
const value = step.inputs?.[field.id] ?? '';
|
||||
@@ -188,7 +198,7 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
|
||||
}
|
||||
|
||||
// Webhook bez registrovaneho tokenu nelze zavolat.
|
||||
const isWebhook = flow.trigger.connectorId === 'webhook';
|
||||
const isWebhook = flow.trigger.serviceId === 'webhook';
|
||||
if (isWebhook && !flow.trigger.webhookToken) {
|
||||
issues.push('Webhook nemá vygenerovanou adresu.');
|
||||
}
|
||||
@@ -244,7 +254,7 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean {
|
||||
if (step.kind === 'condition') {
|
||||
return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category);
|
||||
}
|
||||
return findConnector(step.connectorId)?.category === category;
|
||||
return findService(step.serviceId)?.category === category;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,9 +266,9 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean {
|
||||
function deriveKind(flow: AutomationFlow): AutomationKind {
|
||||
if (!flow.trigger) return 'workflow';
|
||||
|
||||
const connector = findConnector(flow.trigger.connectorId);
|
||||
const connector = findService(flow.trigger.serviceId);
|
||||
if (!connector) {
|
||||
console.warn(`[automations] spoustec odkazuje na neznamy konektor: ${flow.trigger.connectorId}`);
|
||||
console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`);
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
@@ -313,7 +323,7 @@ seed({
|
||||
lastRunAt: minutesAgo(3),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'eshop',
|
||||
serviceId: 'eshop',
|
||||
operationId: 'order-created',
|
||||
fields: [
|
||||
{ id: 'f_1', name: 'orderId', type: 'string', required: true },
|
||||
@@ -322,8 +332,8 @@ seed({
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_1', kind: 'action', connectorId: 'transform', operationId: 'map-fields' },
|
||||
{ id: 'st_2', kind: 'action', connectorId: 'eshop', operationId: 'update-stock' },
|
||||
{ id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' },
|
||||
{ id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' },
|
||||
{
|
||||
id: 'st_3',
|
||||
kind: 'condition',
|
||||
@@ -331,12 +341,12 @@ seed({
|
||||
operator: 'gte',
|
||||
value: '5000',
|
||||
yes: [
|
||||
{ id: 'st_4', kind: 'action', connectorId: 'idoklad', operationId: 'create-proforma' },
|
||||
{ id: 'st_5', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' },
|
||||
{ id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
no: [{ id: 'st_6', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' }],
|
||||
no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }],
|
||||
},
|
||||
{ id: 'st_7', kind: 'action', connectorId: 'ppl', operationId: 'create-shipment' },
|
||||
{ id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -350,7 +360,7 @@ seed({
|
||||
lastRunAt: minutesAgo(11),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'voicebot',
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
fields: [
|
||||
{ id: 'f_11', name: 'callerNumber', type: 'string', required: true },
|
||||
@@ -358,18 +368,18 @@ seed({
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_11', kind: 'action', connectorId: 'voicebot', operationId: 'play-scenario' },
|
||||
{ id: 'st_12', kind: 'action', connectorId: 'transcription', operationId: 'transcribe' },
|
||||
{ id: 'st_13', kind: 'action', connectorId: 'ai-text', operationId: 'classify' },
|
||||
{ id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' },
|
||||
{ id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' },
|
||||
{ id: 'st_13', kind: 'action', serviceId: 'ai-text', operationId: 'classify' },
|
||||
{
|
||||
id: 'st_14',
|
||||
kind: 'condition',
|
||||
fieldId: 'f_12',
|
||||
operator: 'isTrue',
|
||||
yes: [{ id: 'st_15', kind: 'action', connectorId: 'voicebot', operationId: 'transfer' }],
|
||||
yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }],
|
||||
no: [
|
||||
{ id: 'st_16', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_17', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -385,14 +395,14 @@ seed({
|
||||
lastRunAt: minutesAgo(26),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'company-changed',
|
||||
fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_21', kind: 'action', connectorId: 'transform', operationId: 'deduplicate' },
|
||||
{ id: 'st_22', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' },
|
||||
{ id: 'st_23', kind: 'action', connectorId: 'log', operationId: 'write' },
|
||||
{ id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' },
|
||||
{ id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' },
|
||||
{ id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -405,13 +415,13 @@ seed({
|
||||
avgDurationMs: 18_400,
|
||||
lastRunAt: minutesAgo(1_020),
|
||||
flow: {
|
||||
trigger: { connectorId: 'scheduler', operationId: 'interval', fields: [] },
|
||||
trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] },
|
||||
steps: [
|
||||
{ id: 'st_31', kind: 'action', connectorId: 'ga4', operationId: 'run-report' },
|
||||
{ id: 'st_32', kind: 'action', connectorId: 'google-ads', operationId: 'campaign-report' },
|
||||
{ id: 'st_33', kind: 'action', connectorId: 'sklik', operationId: 'campaign-report' },
|
||||
{ id: 'st_34', kind: 'action', connectorId: 'ai-text', operationId: 'generate' },
|
||||
{ id: 'st_35', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' },
|
||||
{ id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' },
|
||||
{ id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' },
|
||||
{ id: 'st_34', kind: 'action', serviceId: 'ai-text', operationId: 'generate' },
|
||||
{ id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -426,7 +436,7 @@ seed({
|
||||
lastRunAt: minutesAgo(18),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'webhook',
|
||||
serviceId: 'webhook',
|
||||
operationId: 'received',
|
||||
webhookToken: generateWebhookToken(),
|
||||
fields: [
|
||||
@@ -442,12 +452,12 @@ seed({
|
||||
fieldId: 'f_42',
|
||||
operator: 'gte',
|
||||
value: '15',
|
||||
yes: [{ id: 'st_42', kind: 'action', connectorId: 'raynet', operationId: 'add-activity' }],
|
||||
yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }],
|
||||
no: [
|
||||
{
|
||||
id: 'st_43',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'Nízké hodnocení od {{customer}}',
|
||||
@@ -457,7 +467,7 @@ seed({
|
||||
assigneeId: 'ppl_vomacka',
|
||||
},
|
||||
},
|
||||
{ id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' },
|
||||
{ id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -478,7 +488,7 @@ seed({
|
||||
lastRunAt: minutesAgo(7),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'whatsapp',
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
fields: [
|
||||
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
|
||||
@@ -493,7 +503,7 @@ seed({
|
||||
{
|
||||
id: 'st_51',
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'find-company',
|
||||
inputs: { phone: '{{phone}}' },
|
||||
},
|
||||
@@ -507,7 +517,7 @@ seed({
|
||||
{
|
||||
id: 'st_53',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'WhatsApp od {{profileName}}',
|
||||
@@ -524,7 +534,7 @@ seed({
|
||||
{
|
||||
id: 'st_54',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'WhatsApp od neznámého čísla',
|
||||
@@ -535,7 +545,7 @@ seed({
|
||||
assigneeId: '',
|
||||
},
|
||||
},
|
||||
{ id: 'st_55', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -551,7 +561,7 @@ seed({
|
||||
lastRunAt: minutesAgo(52),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'facebook',
|
||||
serviceId: 'facebook',
|
||||
operationId: 'message-received',
|
||||
fields: [
|
||||
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
|
||||
@@ -567,7 +577,7 @@ seed({
|
||||
{
|
||||
id: 'st_61',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'Facebook od {{senderName}}',
|
||||
@@ -591,7 +601,7 @@ seed({
|
||||
lastRunAt: minutesAgo(14),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
fields: [
|
||||
{ id: 'email.from', name: 'from', type: 'string', required: true },
|
||||
@@ -605,7 +615,7 @@ seed({
|
||||
{
|
||||
id: 'st_65',
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'find-company',
|
||||
inputs: { email: '{{from}}' },
|
||||
},
|
||||
@@ -618,7 +628,7 @@ seed({
|
||||
{
|
||||
id: 'st_67',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: '{{subject}}',
|
||||
@@ -635,7 +645,7 @@ seed({
|
||||
{
|
||||
id: 'st_68',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: '{{subject}}',
|
||||
@@ -649,7 +659,7 @@ seed({
|
||||
{
|
||||
id: 'st_69',
|
||||
kind: 'action',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'send',
|
||||
inputs: {
|
||||
to: '{{from}}',
|
||||
@@ -676,7 +686,7 @@ seed({
|
||||
lastRunAt: minutesAgo(4),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'created',
|
||||
fields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
@@ -708,7 +718,7 @@ seed({
|
||||
{
|
||||
id: 'st_73',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' },
|
||||
},
|
||||
@@ -724,7 +734,7 @@ seed({
|
||||
{
|
||||
id: 'st_75',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' },
|
||||
},
|
||||
@@ -733,7 +743,7 @@ seed({
|
||||
{
|
||||
id: 'st_76',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' },
|
||||
},
|
||||
@@ -873,7 +883,7 @@ function withWebhookToken(
|
||||
): AutomationFlow {
|
||||
if (!next.trigger) return next;
|
||||
|
||||
if (next.trigger.connectorId !== 'webhook') {
|
||||
if (next.trigger.serviceId !== 'webhook') {
|
||||
if (next.trigger.webhookToken) {
|
||||
console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`);
|
||||
}
|
||||
@@ -882,7 +892,7 @@ function withWebhookToken(
|
||||
|
||||
// Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama.
|
||||
const keptToken =
|
||||
previous.trigger?.connectorId === 'webhook' ? previous.trigger.webhookToken : undefined;
|
||||
previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined;
|
||||
|
||||
if (keptToken) {
|
||||
return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } };
|
||||
@@ -903,7 +913,7 @@ export function regenerateWebhookToken(
|
||||
console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
if (stored.flow.trigger?.connectorId !== 'webhook') {
|
||||
if (stored.flow.trigger?.serviceId !== 'webhook') {
|
||||
console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Konektory = napojeni jedne firmy na jednu sluzbu.
|
||||
*
|
||||
* Sluzba (`services.ts`) rika, co je potreba: "iDoklad chce hlavicky
|
||||
* X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje.
|
||||
*
|
||||
* Proto **pristupove udaje nejsou v environment variables**. Kdyby byly,
|
||||
* mela by je cela instance spolecne a dve firmy by fakturovaly ze stejneho
|
||||
* uctu. Napojeni je vlastnost firmy, ne prostredi.
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu.
|
||||
* Cilovy stav je Postgres se sifrovanymi hodnotami, viz
|
||||
* documentation/09-navrh-rozsireni.md, bod 9.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { findService, type Service, type ServiceCredentialField } from './services.js';
|
||||
|
||||
export interface Connector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
/** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */
|
||||
name: string;
|
||||
/** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */
|
||||
baseUrl: string | null;
|
||||
/** Hodnoty poli z `Service.credentials`. Tajne se z API nikdy nevraci. */
|
||||
values: Record<string, string>;
|
||||
enabled: boolean;
|
||||
/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */
|
||||
status: 'untested' | 'ok' | 'error';
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
/** Krok stromu bez vybraneho konektoru pouzije vychozi. */
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt -
|
||||
* secrets se nevraci z beznych endpointu (AGENTS.md).
|
||||
*/
|
||||
export interface PublicConnector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
status: Connector['status'];
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */
|
||||
filled: string[];
|
||||
/** ID povinnych poli, ktera jeste chybi. */
|
||||
missing: string[];
|
||||
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
|
||||
config: Record<string, string>;
|
||||
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const connectors: Connector[] = [];
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Povinna pole, ktera nejsou vyplnena. */
|
||||
function missingFields(service: Service, values: Record<string, string>): string[] {
|
||||
return service.credentials
|
||||
.filter((field) => field.required && (values[field.id] ?? '').trim() === '')
|
||||
.map((field) => field.id);
|
||||
}
|
||||
|
||||
export function toPublicConnector(connector: Connector): PublicConnector {
|
||||
const service = findService(connector.serviceId);
|
||||
const credentials = service?.credentials ?? [];
|
||||
|
||||
const filled = Object.entries(connector.values)
|
||||
.filter(([, value]) => value.trim() !== '')
|
||||
.map(([key]) => key);
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const field of credentials) {
|
||||
if (field.secret) continue;
|
||||
const value = connector.values[field.id];
|
||||
if (value !== undefined && value.trim() !== '') config[field.id] = value;
|
||||
}
|
||||
|
||||
const missing = service ? missingFields(service, connector.values) : [];
|
||||
|
||||
const { values: _values, ...rest } = connector;
|
||||
return { ...rest, filled, missing, config, ready: missing.length === 0 };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- cteni
|
||||
|
||||
/**
|
||||
* Filtr na firmu je **povinny argument**, ne volitelny. Zapomenuty filtr tak
|
||||
* neznamena "vse", ale nezkompiluje se. Stejne jako u ticketu.
|
||||
*/
|
||||
export function listConnectors(
|
||||
tenantIds: string[],
|
||||
options: { serviceId?: string } = {},
|
||||
): Connector[] {
|
||||
return connectors
|
||||
.filter((connector) => tenantIds.includes(connector.tenantId))
|
||||
.filter((connector) => !options.serviceId || connector.serviceId === options.serviceId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
/** Cizi konektor se chova jako neexistujici, tedy undefined, ne chyba prava. */
|
||||
export function getConnector(id: string, tenantIds: string[]): Connector | undefined {
|
||||
const connector = connectors.find((item) => item.id === id);
|
||||
if (!connector) return undefined;
|
||||
return tenantIds.includes(connector.tenantId) ? connector : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vychozi konektor firmy pro danou sluzbu.
|
||||
* Krok stromu bez vybraneho konektoru pouzije tenhle, diky tomu je vzorovy
|
||||
* strom prenositelny mezi firmami.
|
||||
*/
|
||||
export function defaultConnectorFor(tenantId: string, serviceId: string): Connector | undefined {
|
||||
const forService = connectors.filter(
|
||||
(connector) =>
|
||||
connector.tenantId === tenantId && connector.serviceId === serviceId && connector.enabled,
|
||||
);
|
||||
return forService.find((connector) => connector.isDefault) ?? forService[0];
|
||||
}
|
||||
|
||||
/** Kolik konektoru ma firma na kterou sluzbu. Podle toho se kresli stav v katalogu. */
|
||||
export function connectorCountsByService(tenantIds: string[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const connector of listConnectors(tenantIds)) {
|
||||
counts.set(connector.serviceId, (counts.get(connector.serviceId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- validace
|
||||
|
||||
export interface ConnectorIssue {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi hodnoty proti tomu, co sluzba vyzaduje.
|
||||
*
|
||||
* Nevyplnene povinne pole **neni chyba** ukladani, ale nedodelek: konektor se
|
||||
* ulozi a jen nepujde pouzit. Stejny rezim jako u rozdelane automatizace -
|
||||
* rozdelana prace se nezahazuje.
|
||||
*
|
||||
* Chyba je jen pole, ktere sluzba vubec nema. To uz je rozbite napojeni.
|
||||
*/
|
||||
export function validateConnectorValues(
|
||||
service: Service,
|
||||
values: Record<string, string>,
|
||||
): ConnectorIssue[] {
|
||||
const known = new Map<string, ServiceCredentialField>(
|
||||
service.credentials.map((field) => [field.id, field]),
|
||||
);
|
||||
|
||||
const issues: ConnectorIssue[] = [];
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!known.has(key)) {
|
||||
issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- zapis
|
||||
|
||||
export interface CreateConnectorInput {
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl?: string | null;
|
||||
values?: Record<string, string>;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function createConnector(input: CreateConnectorInput): Connector {
|
||||
const timestamp = now();
|
||||
const existing = listConnectors([input.tenantId], { serviceId: input.serviceId });
|
||||
|
||||
const connector: Connector = {
|
||||
id: `con_${randomUUID().slice(0, 8)}`,
|
||||
tenantId: input.tenantId,
|
||||
serviceId: input.serviceId,
|
||||
name: input.name,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
values: { ...(input.values ?? {}) },
|
||||
enabled: true,
|
||||
status: 'untested',
|
||||
lastCheckAt: null,
|
||||
lastError: null,
|
||||
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
|
||||
isDefault: input.isDefault ?? existing.length === 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId);
|
||||
connectors.push(connector);
|
||||
console.info(`[connectors] zalozen ${connector.id} (${connector.serviceId}) pro ${connector.tenantId}`);
|
||||
return connector;
|
||||
}
|
||||
|
||||
function clearDefaults(tenantId: string, serviceId: string): void {
|
||||
for (const connector of connectors) {
|
||||
if (connector.tenantId === tenantId && connector.serviceId === serviceId) {
|
||||
connector.isDefault = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateConnectorInput {
|
||||
name?: string;
|
||||
baseUrl?: string | null;
|
||||
/**
|
||||
* Jen pole, ktera se meni. Prazdny retezec hodnotu **smaze**, chybejici klic
|
||||
* ji nechá. Diky tomu jde ulozit formular, ktery tajne hodnoty neposila.
|
||||
*/
|
||||
values?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function updateConnector(
|
||||
id: string,
|
||||
patch: UpdateConnectorInput,
|
||||
tenantIds: string[],
|
||||
): Connector | undefined {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return undefined;
|
||||
|
||||
if (patch.name !== undefined) connector.name = patch.name;
|
||||
if (patch.baseUrl !== undefined) connector.baseUrl = patch.baseUrl;
|
||||
if (patch.enabled !== undefined) connector.enabled = patch.enabled;
|
||||
|
||||
if (patch.values) {
|
||||
for (const [key, value] of Object.entries(patch.values)) {
|
||||
if (value === '') delete connector.values[key];
|
||||
else connector.values[key] = value;
|
||||
}
|
||||
// Zmena udaju znamena, ze predchozi overeni uz nic nerika.
|
||||
connector.status = 'untested';
|
||||
connector.lastError = null;
|
||||
}
|
||||
|
||||
if (patch.isDefault === true) {
|
||||
clearDefaults(connector.tenantId, connector.serviceId);
|
||||
connector.isDefault = true;
|
||||
}
|
||||
|
||||
connector.updatedAt = now();
|
||||
return connector;
|
||||
}
|
||||
|
||||
export function deleteConnector(id: string, tenantIds: string[]): boolean {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return false;
|
||||
|
||||
const index = connectors.indexOf(connector);
|
||||
connectors.splice(index, 1);
|
||||
|
||||
// Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu
|
||||
// prestaly fungovat, aniz by se cokoliv jineho zmenilo.
|
||||
if (connector.isDefault) {
|
||||
const next = listConnectors([connector.tenantId], { serviceId: connector.serviceId })[0];
|
||||
if (next) next.isDefault = true;
|
||||
}
|
||||
|
||||
console.info(`[connectors] smazan ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Vysledek overeni napojeni. Zapisuje ho endpoint pro test. */
|
||||
export function setConnectorStatus(
|
||||
id: string,
|
||||
status: Connector['status'],
|
||||
error: string | null,
|
||||
tenantIds: string[],
|
||||
): Connector | undefined {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return undefined;
|
||||
|
||||
connector.status = status;
|
||||
connector.lastError = error;
|
||||
connector.lastCheckAt = now();
|
||||
connector.updatedAt = connector.lastCheckAt;
|
||||
return connector;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- vychozi sada
|
||||
|
||||
/**
|
||||
* Jeden ukazkovy konektor bez vyplnenych udaju.
|
||||
*
|
||||
* Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy
|
||||
* "sluzba je napojena, ale chybi pristupove udaje". Vyplnit je jde v portalu.
|
||||
*/
|
||||
createConnector({
|
||||
tenantId: 'tnt_automia',
|
||||
serviceId: 'idoklad',
|
||||
name: 'iDoklad Automia',
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* Server je autorita, kopie na klientovi existuje jen kvuli UI.
|
||||
*/
|
||||
|
||||
import { actionOutputsFor } from './connectors.js';
|
||||
import { actionOutputsFor } from './services.js';
|
||||
import type { AutomationFlow, FlowStep, TriggerField } from './automationStore.js';
|
||||
|
||||
export interface FlowScopes {
|
||||
@@ -30,7 +30,7 @@ export interface FlowScopes {
|
||||
|
||||
/** Vystupy akce jako pole se stabilnim ID v ramci celeho stromu. */
|
||||
function outputsOf(step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] {
|
||||
return actionOutputsFor(step.connectorId, step.operationId).map((output) => ({
|
||||
return actionOutputsFor(step.serviceId, step.operationId).map((output) => ({
|
||||
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
|
||||
id: `${step.id}.${output.id}`,
|
||||
name: output.name,
|
||||
|
||||
@@ -1,30 +1,83 @@
|
||||
/**
|
||||
* Katalog konektoru = zdroj pravdy o tom, co lze v automatizaci pouzit.
|
||||
* Katalog SLUZEB = zdroj pravdy o tom, co lze v automatizaci a v akcich pouzit.
|
||||
*
|
||||
* Kazdy konektor ma:
|
||||
* Pozor na dve veci, ktere se snadno pletou:
|
||||
* - **Sluzba** je to, co umime. iDoklad, Shoptet, tickety, HTTP pozadavek.
|
||||
* Definujeme ji my, ma svoje operace a rika, co je potreba k napojeni.
|
||||
* - **Konektor** je napojeni jedne firmy na jednu sluzbu vcetne jejich
|
||||
* pristupovych udaju. Zaklada si ho firma, uloziste je `connectorStore.ts`.
|
||||
*
|
||||
* Sluzba tedy rika "iDoklad potrebuje X-ClientId a X-ClientSecret",
|
||||
* konektor rika "a tohle jsou nase".
|
||||
*
|
||||
* Kazda sluzba ma:
|
||||
* - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec)
|
||||
* - actions: co se s nim da UDELAT uprostred behu
|
||||
* - actions: co se s ni da UDELAT uprostred behu
|
||||
*
|
||||
* Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat novy konektor: documentation/05-dashboard-a-builder.md
|
||||
* Sluzba muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md
|
||||
*/
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
import { people } from './people.js';
|
||||
import type { User } from '../types.js';
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
export type ServiceCategory =
|
||||
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
|
||||
| 'obecne'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
| 'ai';
|
||||
|
||||
/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
/**
|
||||
* available = sluzbu umime, planned = je na roadmape.
|
||||
*
|
||||
* "Napojeno" tady zamerne NENI. Jestli je sluzba napojena, zavisi na tom,
|
||||
* jestli si firma vytvorila konektor - to je vlastnost firmy, ne sluzby.
|
||||
* Pocita se v `serviceViewsFor`.
|
||||
*/
|
||||
export type ServiceStatus = 'available' | 'planned';
|
||||
|
||||
/**
|
||||
* Kdo sluzbu vidi.
|
||||
*
|
||||
* `everyone` vsichni prihlaseni
|
||||
* `restricted` jen uvedene firmy a jmenovite uvedeni lide
|
||||
* `admin` jen spravce platformy
|
||||
*
|
||||
* Spravce platformy vidi vzdy vsechno. Obecne sluzby (`general: true`) taky
|
||||
* vidi vzdy vsichni, viditelnost se u nich neresi.
|
||||
*/
|
||||
export interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
/** Jen u `restricted`: firmy, ktere sluzbu vidi. */
|
||||
tenantIds: string[];
|
||||
/** Jen u `restricted`: konkretni lide bez ohledu na firmu. */
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru.
|
||||
*
|
||||
* `target: 'header'` znamena, ze se hodnota posila jako hlavicka requestu
|
||||
* pojmenovana v `name`. Tim je popsane, co ktera sluzba vyzaduje, a konektor
|
||||
* uz jen doplni hodnoty.
|
||||
*
|
||||
* `secret: true` se **nikdy nevraci z API** a redaguje se v logu.
|
||||
*/
|
||||
export interface ServiceCredentialField {
|
||||
id: string;
|
||||
label: string;
|
||||
target: 'header' | 'config';
|
||||
/** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */
|
||||
name: string;
|
||||
required: boolean;
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
|
||||
@@ -58,7 +111,7 @@ export interface OperationField {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorOperation {
|
||||
export interface ServiceOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -92,23 +145,48 @@ export interface ConnectorOperation {
|
||||
/**
|
||||
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
|
||||
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
|
||||
* Doplnuje `src/scripts/catalog.ts`, rucne se to nepise.
|
||||
* Doplnuje `src/scripts/registry.ts`, rucne se to nepise.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
category: ServiceCategory;
|
||||
description: string;
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/connectorIcons.ts). */
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/serviceIcons.ts). */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
status: ServiceStatus;
|
||||
/**
|
||||
* true = sluzba funguje bez konektoru. Obecne veci, ktere ma kazdy:
|
||||
* webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, log.
|
||||
*/
|
||||
general: boolean;
|
||||
/**
|
||||
* ID aplikace v services.csbot.cz/apps. Zaklad adresy se sklada z nej
|
||||
* a ze `SERVICES_BASE_URL`, verejna domena se nikdy nehardcoduje (AGENTS.md).
|
||||
* null = sluzba se nevola pres jednu pevnou adresu.
|
||||
*/
|
||||
appId: string | null;
|
||||
visibility: ServiceVisibility;
|
||||
/**
|
||||
* Co je potreba vyplnit pri zakladani konektoru.
|
||||
* Hodnoty patri konektoru, ne prostredi - kazda firma ma svoje.
|
||||
* Prazdne pole u obecnych sluzeb a u tech, ktere jeste nemame popsane.
|
||||
*/
|
||||
credentials: ServiceCredentialField[];
|
||||
/**
|
||||
* Levne cteci volani, kterym se overi, ze konektor funguje.
|
||||
* Musi to byt neco, co **nic nemeni** a co vyzaduje autorizaci - jinak by test
|
||||
* prosel i se spatnymi udaji a rekl uzivateli nepravdu.
|
||||
* Kdyz chybi, overi se jen dostupnost sluzby na `/health`.
|
||||
*/
|
||||
verifyPath?: string;
|
||||
triggers: ServiceOperation[];
|
||||
actions: ServiceOperation[];
|
||||
}
|
||||
|
||||
/** Nabidka resitelu do vyberu u akci. Bere se ze seznamu lidi, aby se nerozesla. */
|
||||
@@ -131,27 +209,29 @@ const statusOptions = [
|
||||
{ value: 'resolved', label: 'Vyřešeno' },
|
||||
];
|
||||
|
||||
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
|
||||
{ id: 'spoustece', label: 'Spouštěče' },
|
||||
{ id: 'servicedesk', label: 'Tickety a servicedesk' },
|
||||
export const serviceCategories: Array<{ id: ServiceCategory; label: string }> = [
|
||||
{ id: 'obecne', label: 'Obecné' },
|
||||
{ id: 'crm', label: 'CRM' },
|
||||
{ id: 'ekonomika', label: 'Ekonomika a banky' },
|
||||
{ id: 'logistika', label: 'Logistika' },
|
||||
{ id: 'komunikace', label: 'Komunikace' },
|
||||
{ id: 'analytika', label: 'Analytika' },
|
||||
{ id: 'ai', label: 'AI a hlas' },
|
||||
{ id: 'nastroje', label: 'Nástroje' },
|
||||
];
|
||||
|
||||
export const connectors: Connector[] = [
|
||||
// ---------------------------------------------------------------- spoustece
|
||||
export const services: Service[] = [
|
||||
// ------------------------------------------------- obecne: spoustece
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
category: 'spoustece',
|
||||
category: 'obecne',
|
||||
description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.',
|
||||
icon: 'Webhook',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'received',
|
||||
@@ -166,10 +246,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'scheduler',
|
||||
name: 'Plánovač',
|
||||
category: 'spoustece',
|
||||
description: 'Spouštění podle času — každou hodinu, denně, nebo podle cron výrazu.',
|
||||
category: 'obecne',
|
||||
description: 'Spouštění podle času, každou hodinu, denně, nebo podle cron výrazu.',
|
||||
icon: 'Clock',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'interval',
|
||||
@@ -183,10 +267,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
name: 'Ruční spuštění',
|
||||
category: 'spoustece',
|
||||
category: 'obecne',
|
||||
description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.',
|
||||
icon: 'MousePointerClick',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'button',
|
||||
@@ -199,10 +287,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'form',
|
||||
name: 'Webový formulář',
|
||||
category: 'spoustece',
|
||||
description: 'Odeslání formuláře z webu — poptávka, registrace, reklamace.',
|
||||
category: 'obecne',
|
||||
description: 'Odeslání formuláře z webu: poptávka, registrace, reklamace.',
|
||||
icon: 'FileInput',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'submitted',
|
||||
@@ -223,11 +315,15 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'ticket',
|
||||
name: 'Tickety',
|
||||
category: 'servicedesk',
|
||||
category: 'obecne',
|
||||
description:
|
||||
'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.',
|
||||
icon: 'LifeBuoy',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'created',
|
||||
@@ -398,7 +494,11 @@ export const connectors: Connector[] = [
|
||||
category: 'crm',
|
||||
description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.',
|
||||
icon: 'Users',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'raynet',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'lead-created',
|
||||
@@ -470,9 +570,52 @@ export const connectors: Connector[] = [
|
||||
id: 'idoklad',
|
||||
name: 'iDoklad',
|
||||
category: 'ekonomika',
|
||||
description: 'Fakturace — vydané i přijaté doklady, kontakty, úhrady.',
|
||||
description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.',
|
||||
icon: 'Receipt',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'idoklad',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [
|
||||
{
|
||||
id: 'clientId',
|
||||
label: 'Client ID',
|
||||
target: 'header',
|
||||
name: 'X-ClientId',
|
||||
required: true,
|
||||
secret: false,
|
||||
hint: 'Z vývojářského portálu iDokladu.',
|
||||
},
|
||||
{
|
||||
id: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
target: 'header',
|
||||
name: 'X-ClientSecret',
|
||||
required: true,
|
||||
secret: true,
|
||||
hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.',
|
||||
},
|
||||
{
|
||||
id: 'applicationId',
|
||||
label: 'Application ID',
|
||||
target: 'header',
|
||||
name: 'X-ApplicationId',
|
||||
required: false,
|
||||
secret: false,
|
||||
hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk odpovědí',
|
||||
target: 'header',
|
||||
name: 'X-Idoklad-Language',
|
||||
required: false,
|
||||
secret: false,
|
||||
hint: 'Cz, Sk nebo En.',
|
||||
},
|
||||
],
|
||||
// Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde.
|
||||
verifyPath: '/account/agenda',
|
||||
triggers: [
|
||||
{
|
||||
id: 'invoice-paid',
|
||||
@@ -512,7 +655,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ekonomika',
|
||||
description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.',
|
||||
icon: 'Landmark',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'csob',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'payment-received',
|
||||
@@ -543,7 +690,11 @@ export const connectors: Connector[] = [
|
||||
category: 'logistika',
|
||||
description: 'Zásilky, štítky a svozy v systému PPL.',
|
||||
icon: 'Truck',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ppl',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'shipment-delivered',
|
||||
@@ -579,6 +730,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).',
|
||||
icon: 'ShoppingCart',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'eshop',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'order-created',
|
||||
@@ -614,7 +769,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Příjem i odesílání e-mailů včetně příloh.',
|
||||
icon: 'Mail',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'email',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'received',
|
||||
@@ -655,7 +814,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.',
|
||||
icon: 'MessageCircle',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'whatsapp',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -700,7 +863,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Zprávy z firemní stránky na Facebooku.',
|
||||
icon: 'Facebook',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'facebook',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -740,6 +907,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Přímé zprávy na firemním účtu Instagramu.',
|
||||
icon: 'Instagram',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'instagram',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -777,7 +948,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.',
|
||||
icon: 'Building2',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'microsoft365',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'calendar-event',
|
||||
@@ -813,6 +988,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.',
|
||||
icon: 'MessageSquare',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'sms',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -830,6 +1009,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Notifikace a interní komunikace v Slacku.',
|
||||
icon: 'Hash',
|
||||
status: 'planned',
|
||||
general: false,
|
||||
appId: 'slack',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -848,7 +1031,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Návštěvnost, konverze a chování uživatelů.',
|
||||
icon: 'BarChart3',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ga4',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -865,7 +1052,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Pozice ve vyhledávání, dotazy a prokliky.',
|
||||
icon: 'Search',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'search-console',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -882,7 +1073,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Výkon kampaní a náklady na reklamu.',
|
||||
icon: 'Megaphone',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'google-ads',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -899,7 +1094,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Kampaně a náklady v Skliku.',
|
||||
icon: 'MousePointer',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'sklik',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -916,9 +1115,13 @@ export const connectors: Connector[] = [
|
||||
id: 'voicebot',
|
||||
name: 'Voicebot',
|
||||
category: 'ai',
|
||||
description: 'Hlasová linka — příjem hovorů, rozpoznání záměru, předání operátorovi.',
|
||||
description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.',
|
||||
icon: 'PhoneCall',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'voicebot',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'call-received',
|
||||
@@ -964,7 +1167,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ai',
|
||||
description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.',
|
||||
icon: 'FileAudio',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'transcription',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -987,7 +1194,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ai',
|
||||
description: 'Klasifikace, extrakce dat a generování textu jazykovým modelem.',
|
||||
icon: 'Sparkles',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ai-text',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1024,14 +1235,69 @@ export const connectors: Connector[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- nastroje
|
||||
/**
|
||||
* Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce
|
||||
* platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani
|
||||
* nedozvi, ze existuje.
|
||||
*/
|
||||
{
|
||||
id: 'polstryn-sap',
|
||||
name: 'Polstryn SAP',
|
||||
category: 'ekonomika',
|
||||
description: 'Zakázková integrace na podnikový systém jednoho klienta.',
|
||||
icon: 'Boxes',
|
||||
status: 'planned',
|
||||
general: false,
|
||||
appId: 'polstryn-sap',
|
||||
visibility: { mode: 'restricted', tenantIds: ['tnt_logitrans'], userIds: [] },
|
||||
credentials: [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: 'API klíč',
|
||||
target: 'header',
|
||||
name: 'X-Api-Key',
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
id: 'plant',
|
||||
label: 'Číslo závodu',
|
||||
target: 'config',
|
||||
name: 'plant',
|
||||
required: true,
|
||||
secret: false,
|
||||
hint: 'Předá se skriptu jako ctx.config.plant.',
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
{
|
||||
id: 'order-released',
|
||||
name: 'Uvolněna výrobní zakázka',
|
||||
description: 'Spustí se, jakmile SAP uvolní zakázku do výroby.',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'post-goods-issue',
|
||||
name: 'Zaúčtovat výdej materiálu',
|
||||
description: 'Zapíše výdej materiálu k zakázce.',
|
||||
fields: ['Číslo zakázky', 'Materiál', 'Množství'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------- obecne: nastroje
|
||||
{
|
||||
id: 'http',
|
||||
name: 'HTTP požadavek',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Zavolá libovolné API, které nemá vlastní konektor.',
|
||||
icon: 'Globe',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1045,10 +1311,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'transform',
|
||||
name: 'Transformace dat',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Přemapování polí, formátování a čištění dat mezi kroky.',
|
||||
icon: 'Shuffle',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1068,10 +1338,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'delay',
|
||||
name: 'Pauza',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.',
|
||||
icon: 'Timer',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1085,90 +1359,125 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Zápis do logu',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Uloží zprávu do provozního logu automatizace.',
|
||||
icon: 'ScrollText',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'write',
|
||||
name: 'Zapsat zprávu',
|
||||
description: 'Přidá záznam do historie běhu — užitečné při ladění.',
|
||||
description: 'Přidá záznam do historie běhu, užitečné při ladění.',
|
||||
fields: ['Úroveň', 'Zpráva'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findConnector(connectorId: string): Connector | undefined {
|
||||
return connectors.find((c) => c.id === connectorId);
|
||||
export function findService(serviceId: string): Service | undefined {
|
||||
return services.find((service) => service.id === serviceId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- kdo co vidi
|
||||
|
||||
/**
|
||||
* Vidi uzivatel tuhle sluzbu?
|
||||
*
|
||||
* Obecne sluzby vidi vzdy vsichni - webhook, pauza nebo transformace dat nejsou
|
||||
* nic, co by se komu odepiralo. Spravce platformy vidi vzdy vsechno.
|
||||
*
|
||||
* Sluzba, kterou uzivatel nevidi, se **nevraci vubec**, ne se stavem "nemate
|
||||
* pravo". Firma nema z odpovedi poznat, ze taková sluzba existuje - stejne
|
||||
* pravidlo jako u ticketu v documentation/07-firmy-a-prava.md.
|
||||
*/
|
||||
export function canSeeService(service: Service, user: User, tenantId: string | null): boolean {
|
||||
if (service.general) return true;
|
||||
if (user.platformAdmin) return true;
|
||||
|
||||
switch (service.visibility.mode) {
|
||||
case 'everyone':
|
||||
return true;
|
||||
case 'admin':
|
||||
return false;
|
||||
case 'restricted':
|
||||
if (service.visibility.userIds.includes(user.id)) return true;
|
||||
return tenantId !== null && service.visibility.tenantIds.includes(tenantId);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleServices(user: User, tenantId: string | null): Service[] {
|
||||
return services.filter((service) => canSeeService(service, user, tenantId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- akce ze skriptu
|
||||
|
||||
/**
|
||||
* Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
|
||||
* Akce domerene ze skriptu sluzby. Plni to `src/scripts/registry.ts`
|
||||
* pri kazdem nacteni skriptu.
|
||||
*
|
||||
* Je to prekryv, ne zapis do `connectors`. Dva duvody: staticky katalog
|
||||
* zustane citelny a z operace jde poznat, odkud je (`implementation`).
|
||||
* Je to prekryv, ne zapis do `services`. Dva duvody: staticky katalog zustane
|
||||
* citelny a z operace jde poznat, odkud je (`implementation`).
|
||||
*
|
||||
* Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
|
||||
* uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
|
||||
* stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
|
||||
*/
|
||||
const scriptActions = new Map<string, ConnectorOperation[]>();
|
||||
const scriptActions = new Map<string, ServiceOperation[]>();
|
||||
|
||||
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
|
||||
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): void {
|
||||
export function setScriptActions(byService: Map<string, ServiceOperation[]>): void {
|
||||
scriptActions.clear();
|
||||
for (const [connectorId, operations] of byConnector) {
|
||||
scriptActions.set(connectorId, operations);
|
||||
for (const [serviceId, operations] of byService) {
|
||||
scriptActions.set(serviceId, operations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Akce konektoru vcetne tech ze skriptu.
|
||||
* Akce sluzby vcetne tech ze skriptu.
|
||||
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
|
||||
* Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
|
||||
*/
|
||||
export function actionsFor(connectorId: string): ConnectorOperation[] {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return [];
|
||||
export function actionsFor(serviceId: string): ServiceOperation[] {
|
||||
const service = findService(serviceId);
|
||||
if (!service) return [];
|
||||
|
||||
const fromScripts = scriptActions.get(connectorId);
|
||||
if (!fromScripts || fromScripts.length === 0) return connector.actions;
|
||||
const fromScripts = scriptActions.get(serviceId);
|
||||
if (!fromScripts || fromScripts.length === 0) return service.actions;
|
||||
|
||||
const replaced = new Set(fromScripts.map((operation) => operation.id));
|
||||
return [
|
||||
...connector.actions.filter((action) => !replaced.has(action.id)),
|
||||
...service.actions.filter((action) => !replaced.has(action.id)),
|
||||
...fromScripts,
|
||||
].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
/** Katalog pro portal. Nemodifikuje `connectors`, sklada nove objekty. */
|
||||
export function connectorCatalog(): Connector[] {
|
||||
return connectors.map((connector) =>
|
||||
scriptActions.has(connector.id)
|
||||
? { ...connector, actions: actionsFor(connector.id) }
|
||||
: connector,
|
||||
/** Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. */
|
||||
export function serviceCatalog(): Service[] {
|
||||
return services.map((service) =>
|
||||
scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Overi, ze sluzba existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
|
||||
*/
|
||||
export function findOperation(
|
||||
connectorId: string,
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ConnectorOperation | undefined {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return undefined;
|
||||
const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
|
||||
return pool.find((op) => op.id === operationId);
|
||||
): ServiceOperation | undefined {
|
||||
const service = findService(serviceId);
|
||||
if (!service) return undefined;
|
||||
const pool = type === 'trigger' ? service.triggers : actionsFor(serviceId);
|
||||
return pool.find((operation) => operation.id === operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1176,18 +1485,18 @@ export function findOperation(
|
||||
* deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi.
|
||||
*/
|
||||
export function providedFieldsFor(
|
||||
connectorId: string,
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
): ProvidedField[] | undefined {
|
||||
return findOperation(connectorId, operationId, 'trigger')?.providedFields;
|
||||
return findOperation(serviceId, operationId, 'trigger')?.providedFields;
|
||||
}
|
||||
|
||||
/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */
|
||||
export function actionInputsFor(connectorId: string, operationId: string): OperationField[] {
|
||||
return findOperation(connectorId, operationId, 'action')?.inputs ?? [];
|
||||
export function actionInputsFor(serviceId: string, operationId: string): OperationField[] {
|
||||
return findOperation(serviceId, operationId, 'action')?.inputs ?? [];
|
||||
}
|
||||
|
||||
/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */
|
||||
export function actionOutputsFor(connectorId: string, operationId: string): ProvidedField[] {
|
||||
return findOperation(connectorId, operationId, 'action')?.outputFields ?? [];
|
||||
export function actionOutputsFor(serviceId: string, operationId: string): ProvidedField[] {
|
||||
return findOperation(serviceId, operationId, 'action')?.outputFields ?? [];
|
||||
}
|
||||
+25
-25
@@ -61,7 +61,7 @@ export interface TicketTraceEntry {
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
/** Ktera sluzba to byla. null u poznamek a podminek. */
|
||||
connectorId: string | null;
|
||||
serviceId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
@@ -135,7 +135,7 @@ export interface TraceInput {
|
||||
kind: TraceKind;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
connectorId?: string | null;
|
||||
serviceId?: string | null;
|
||||
operationId?: string | null;
|
||||
response?: string | null;
|
||||
durationMs?: number | null;
|
||||
@@ -158,7 +158,7 @@ function flattenTrace(
|
||||
id: nextTraceId(),
|
||||
parentId,
|
||||
kind: input.kind,
|
||||
connectorId: input.connectorId ?? null,
|
||||
serviceId: input.serviceId ?? null,
|
||||
operationId: input.operationId ?? null,
|
||||
label: input.label,
|
||||
status: input.status,
|
||||
@@ -219,7 +219,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'voicebot',
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
label: 'Příchozí hovor na linku 800 100 200',
|
||||
status: 'ok',
|
||||
@@ -229,7 +229,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transcription',
|
||||
serviceId: 'transcription',
|
||||
operationId: 'transcribe',
|
||||
label: 'Přepis nahrávky',
|
||||
status: 'ok',
|
||||
@@ -240,7 +240,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
@@ -250,7 +250,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'ok',
|
||||
@@ -267,7 +267,7 @@ seed(
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -277,7 +277,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele podle služby',
|
||||
status: 'ok',
|
||||
@@ -289,7 +289,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'microsoft365',
|
||||
serviceId: 'microsoft365',
|
||||
operationId: 'post-teams',
|
||||
label: 'Upozornění do Teams',
|
||||
status: 'error',
|
||||
@@ -333,7 +333,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
label: 'Přijat e-mail do schránky podpora@',
|
||||
status: 'ok',
|
||||
@@ -343,7 +343,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'extract',
|
||||
label: 'Vytažení údajů z textu',
|
||||
status: 'ok',
|
||||
@@ -353,7 +353,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle e-mailu',
|
||||
status: 'ok',
|
||||
@@ -363,7 +363,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -373,7 +373,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'send',
|
||||
label: 'Potvrzení zadavateli',
|
||||
status: 'ok',
|
||||
@@ -414,7 +414,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'whatsapp',
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
label: 'Přijata zpráva z WhatsApp',
|
||||
status: 'ok',
|
||||
@@ -425,7 +425,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
@@ -435,7 +435,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'error',
|
||||
@@ -452,7 +452,7 @@ seed(
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
@@ -462,7 +462,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'create-lead',
|
||||
label: 'Založení obchodního případu k dohledání',
|
||||
status: 'ok',
|
||||
@@ -472,7 +472,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele',
|
||||
status: 'skipped',
|
||||
@@ -510,7 +510,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Odeslán formulář Požadavek na úpravu',
|
||||
status: 'ok',
|
||||
@@ -520,7 +520,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -569,7 +569,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Nahlášeno z portálu',
|
||||
status: 'ok',
|
||||
@@ -579,7 +579,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transform',
|
||||
serviceId: 'transform',
|
||||
operationId: 'deduplicate',
|
||||
label: 'Kontrola duplicit v CRM',
|
||||
status: 'ok',
|
||||
|
||||
+212
-8
@@ -26,7 +26,9 @@ export function buildOpenApiDocument() {
|
||||
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
|
||||
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
|
||||
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
|
||||
{ name: 'Skripty', description: 'Vykonna cast konektoru: manifest, kod a zkusebni beh' },
|
||||
{ name: 'Sluzby', description: 'Katalog toho, co umime napojit' },
|
||||
{ name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' },
|
||||
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
|
||||
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
|
||||
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
|
||||
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
|
||||
@@ -99,6 +101,43 @@ export function buildOpenApiDocument() {
|
||||
personId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
Connector: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
|
||||
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'con_1a2b3c4d' },
|
||||
tenantId: { type: 'string', example: 'tnt_automia' },
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
name: { type: 'string', example: 'iDoklad Automia' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
enabled: { type: 'boolean' },
|
||||
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
|
||||
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastError: { type: 'string', nullable: true },
|
||||
isDefault: {
|
||||
type: 'boolean',
|
||||
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
|
||||
},
|
||||
filled: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
|
||||
},
|
||||
missing: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'ID povinnych poli, ktera chybi.',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
|
||||
},
|
||||
ready: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
ScriptField: {
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -135,9 +174,10 @@ export function buildOpenApiDocument() {
|
||||
id: {
|
||||
type: 'string',
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
description: 'Tvar konektor.operace. Nazev souboru musi byt <id>.js.',
|
||||
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
|
||||
},
|
||||
connectorId: { type: 'string', example: 'idoklad' },
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
serviceName: { type: 'string', example: 'iDoklad' },
|
||||
operationId: { type: 'string', example: 'get-issued-invoice' },
|
||||
name: { type: 'string', example: 'Získat vydanou fakturu' },
|
||||
description: { type: 'string' },
|
||||
@@ -947,15 +987,179 @@ export function buildOpenApiDocument() {
|
||||
responses: { '200': { description: 'Proud udalosti text/event-stream' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/services': {
|
||||
get: {
|
||||
tags: ['Sluzby'],
|
||||
summary: 'Katalog sluzeb pro builder',
|
||||
description:
|
||||
'Vraci jen sluzby, ktere uzivatel vidi. Neviditelna sluzba v odpovedi neni ' +
|
||||
'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' +
|
||||
'implementation: script a maji skutecne inputs a outputFields.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Sluzby, kategorie a operatory podminek' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/services': {
|
||||
get: {
|
||||
tags: ['Sluzby'],
|
||||
summary: 'Katalog sluzeb ocima firmy',
|
||||
description:
|
||||
'Jako /services, navic connectorCount, tedy kolik konektoru na sluzbu firma ma. ' +
|
||||
'Podle toho se rozlisi napojeno od muzete si napojit. Neni to vlastnost sluzby, ' +
|
||||
'ale te firmy.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
|
||||
responses: { '200': { description: 'Sluzby vcetne pouziti ve firme' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors': {
|
||||
get: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Katalog konektoru',
|
||||
tags: ['Konektory'],
|
||||
summary: 'Konektory firmy',
|
||||
description:
|
||||
'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
|
||||
'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
|
||||
'Hodnoty pristupovych udaju se NIKDY nevraci, jen filled (co je vyplnene) ' +
|
||||
'a missing (ktera povinna pole chybi).',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
|
||||
parameters: [
|
||||
{ name: 'tenantId', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'serviceId', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Konektory firmy',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Connector' },
|
||||
},
|
||||
tenantId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Zalozit konektor',
|
||||
description:
|
||||
'Pristupove udaje se posilaji ve values s klici podle Service.credentials. ' +
|
||||
'Nevyplnene povinne pole neni chyba, konektor se ulozi a jen nepujde pouzit.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['serviceId', 'name'],
|
||||
properties: {
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
name: { type: 'string', example: 'iDoklad Celo' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Zalozeno',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Connector' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' },
|
||||
'404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/{id}': {
|
||||
get: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Detail konektoru',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { '200': { description: 'Konektor' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
patch: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Upravit konektor',
|
||||
description:
|
||||
'Ve values staci poslat jen to, co se meni. PRAZDNY RETEZEC hodnotu smaze, ' +
|
||||
'chybejici klic ji nechava - diky tomu jde ulozit formular, ktery tajne hodnoty ' +
|
||||
'neposila. Zmena udaju vzdy zrusi predchozi overeni.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
values: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
enabled: { type: 'boolean' },
|
||||
isDefault: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { '200': { description: 'Upraveno' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
delete: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Smazat konektor',
|
||||
description:
|
||||
'Kdyz zmizel vychozi konektor, prevezme to prvni zbyly - jinak by kroky bez ' +
|
||||
'vybraneho konektoru prestaly fungovat.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/{id}/test': {
|
||||
post: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Overit napojeni',
|
||||
description:
|
||||
'Zavola verifyPath sluzby, coz je zamerne cteci volani vyzadujici autorizaci. ' +
|
||||
'Kdyz ho sluzba nema, overi se jen /health a odpoved to v checked rekne - aby ' +
|
||||
'si nikdo nemyslel, ze jsou overene i pristupove udaje. Neuspesne overeni neni ' +
|
||||
'chyba API, vraci se 200 s ok: false.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Vysledek overeni',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
checked: { type: 'string' },
|
||||
status: { type: 'integer' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'404': { description: 'Konektor neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts': {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+68
-105
@@ -1,139 +1,102 @@
|
||||
/**
|
||||
* Napojeni konektoru: kam se vola a cim se to autorizuje.
|
||||
* Kam se vola a cim se to autorizuje.
|
||||
*
|
||||
* Zamerne oddelene od skriptu. Skript rika "GET /issued-invoices/12",
|
||||
* napojeni rika, na jake adrese to je a jaké hlavicky se pridaji. Skript se
|
||||
* tim k pristupovym udajum vubec nedostane.
|
||||
* Skript rika `GET /issued-invoices/12`. Zbytek dodava tenhle soubor ze dvou
|
||||
* mist: **sluzba** rika, ktere hlavicky jsou potreba, **konektor** firmy nese
|
||||
* jejich hodnoty.
|
||||
*
|
||||
* Tady je zatim jedno napojeni na konektor, sestavene z environment variables.
|
||||
* Cilovy stav je napojeni za firmu v databazi, viz documentation/09, bod 9.
|
||||
* Az to bude, prepise se vnitrek `resolveConnection` a nic dalsiho.
|
||||
* Diky tomu se skript k pristupovym udajum vubec nedostane a dve firmy volaji
|
||||
* tutez sluzbu kazda pod svym uctem.
|
||||
*
|
||||
* Z environment variables sem uz nechodi zadne pristupove udaje, jen zaklad
|
||||
* adresy (`SERVICES_BASE_URL`). Udaje patri konektoru.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import type { Connector } from '../data/connectorStore.js';
|
||||
import { findService, type Service } from '../data/services.js';
|
||||
|
||||
export interface ConnectorAuthSpec {
|
||||
/** Hlavicka -> jmeno environment variable, ze ktere se plni. */
|
||||
headers: Record<string, string>;
|
||||
/** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
|
||||
required: string[];
|
||||
/** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autorizace jednotlivych konektoru.
|
||||
*
|
||||
* iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima
|
||||
* `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace.
|
||||
* OAuth tok resi ta sluzba, my posilame jen tyto hlavicky.
|
||||
*/
|
||||
const authSpecs: Record<string, ConnectorAuthSpec> = {
|
||||
idoklad: {
|
||||
headers: {
|
||||
'X-ClientId': 'IDOKLAD_CLIENT_ID',
|
||||
'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET',
|
||||
'X-ApplicationId': 'IDOKLAD_APPLICATION_ID',
|
||||
},
|
||||
required: ['X-ClientId', 'X-ClientSecret'],
|
||||
config: { language: 'IDOKLAD_LANGUAGE' },
|
||||
},
|
||||
};
|
||||
|
||||
export interface ResolvedConnection {
|
||||
connectorId: string;
|
||||
name: string;
|
||||
export interface ResolvedTarget {
|
||||
serviceId: string;
|
||||
connectorId: string | null;
|
||||
/** Prazdne u obecnych sluzeb - `ctx.http` pak vyzaduje absolutni adresu. */
|
||||
baseUrl: string;
|
||||
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
|
||||
headers: Record<string, string>;
|
||||
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
|
||||
config: Record<string, string>;
|
||||
/** false = chybi pristupove udaje, volat nema smysl. */
|
||||
serviceConfig: Record<string, string>;
|
||||
/** false = chybi povinne udaje, volat nema smysl. */
|
||||
ready: boolean;
|
||||
/** Jmena environment variables, ktere chybi. */
|
||||
/** Popisy chybejicich poli pro uzivatele. */
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/** `search-console` -> `SEARCH_CONSOLE`, aby slo skladat jmena promennych. */
|
||||
function envPrefix(connectorId: string): string {
|
||||
return connectorId.replace(/-/g, '_').toUpperCase();
|
||||
}
|
||||
|
||||
function readEnv(name: string): string | undefined {
|
||||
const value = process.env[name];
|
||||
return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
|
||||
* bere se z `SERVICES_BASE_URL` (viz AGENTS.md).
|
||||
* Jednotlive konektory lze presmerovat pres `<KONEKTOR>_BASE_URL`.
|
||||
* bere se ze `SERVICES_BASE_URL` (AGENTS.md).
|
||||
*/
|
||||
function resolveBaseUrl(connectorId: string): string {
|
||||
return (
|
||||
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
|
||||
);
|
||||
export function serviceBaseUrl(service: Service): string {
|
||||
if (service.appId === null) return '';
|
||||
return `${config.servicesBaseUrl}/${service.appId}`;
|
||||
}
|
||||
|
||||
export function resolveConnection(connectorId: string): ResolvedConnection {
|
||||
const spec = authSpecs[connectorId];
|
||||
const headers: Record<string, string> = {};
|
||||
const scriptConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
/**
|
||||
* Slozi adresu a hlavicky pro beh skriptu.
|
||||
*
|
||||
* `connector` je null u obecnych sluzeb, ktere napojeni nepotrebuji. U ostatnich
|
||||
* je jeho absence duvod, proc se skript nespusti - a rekne se to nahlas, ne ze
|
||||
* se zavola bez autorizace a spadne to az na 401.
|
||||
*/
|
||||
export function resolveTarget(serviceId: string, connector: Connector | null): ResolvedTarget {
|
||||
const service = findService(serviceId);
|
||||
|
||||
for (const [header, envName] of Object.entries(spec?.headers ?? {})) {
|
||||
const value = readEnv(envName);
|
||||
if (value !== undefined) headers[header] = value;
|
||||
else if (spec?.required.includes(header)) missing.push(envName);
|
||||
if (!service) {
|
||||
return {
|
||||
serviceId,
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
serviceConfig: {},
|
||||
ready: false,
|
||||
missing: [`Služba ${serviceId} v katalogu neexistuje.`],
|
||||
};
|
||||
}
|
||||
|
||||
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
|
||||
const value = readEnv(envName);
|
||||
if (value !== undefined) scriptConfig[key] = value;
|
||||
const headers: Record<string, string> = {};
|
||||
const serviceConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const field of service.credentials) {
|
||||
const value = (connector?.values[field.id] ?? '').trim();
|
||||
|
||||
if (value === '') {
|
||||
if (field.required) missing.push(field.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.target === 'header') headers[field.name] = value;
|
||||
else serviceConfig[field.name] = value;
|
||||
}
|
||||
|
||||
if (!service.general && connector === null) {
|
||||
missing.push(`Služba ${service.name} potřebuje konektor.`);
|
||||
}
|
||||
if (connector !== null && !connector.enabled) {
|
||||
missing.push(`Konektor ${connector.name} je vypnutý.`);
|
||||
}
|
||||
|
||||
return {
|
||||
connectorId,
|
||||
name: `${connectorId} (z environment variables)`,
|
||||
baseUrl: resolveBaseUrl(connectorId),
|
||||
serviceId,
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
|
||||
headers,
|
||||
config: scriptConfig,
|
||||
serviceConfig,
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
|
||||
export function connectionSecrets(connection: ResolvedConnection): string[] {
|
||||
return Object.values(connection.headers);
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
connectorId: string;
|
||||
baseUrl: string;
|
||||
ready: boolean;
|
||||
/** Jen jmena chybejicich promennych, nikdy hodnoty. */
|
||||
missing: string[];
|
||||
/** Ktere hlavicky jsou vyplnene. Hodnoty se nevraci. */
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stav napojeni pro portal. Vraci se **jen jmena**, nikdy hodnoty -
|
||||
* secrets se z beznych endpointu nevraci (AGENTS.md).
|
||||
*/
|
||||
export function connectionStatus(connectorId: string): ConnectionStatus {
|
||||
const connection = resolveConnection(connectorId);
|
||||
return {
|
||||
connectorId,
|
||||
baseUrl: connection.baseUrl,
|
||||
ready: connection.ready,
|
||||
missing: connection.missing,
|
||||
headers: Object.keys(connection.headers),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ktere konektory maji popsanou autorizaci. */
|
||||
export function connectorsWithAuth(): string[] {
|
||||
return Object.keys(authSpecs);
|
||||
export function targetSecrets(target: ResolvedTarget): string[] {
|
||||
return Object.values(target.headers);
|
||||
}
|
||||
|
||||
+18
-8
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedConnection } from './connections.js';
|
||||
import type { ResolvedTarget } from './connections.js';
|
||||
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
|
||||
import { describe } from './util.js';
|
||||
|
||||
@@ -53,12 +53,22 @@ function assertAllowedUrl(url: URL): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
|
||||
function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOptions): URL {
|
||||
// Obecna sluzba nema pevnou adresu, skript pak musi poslat absolutni.
|
||||
const absolute = target.baseUrl === '';
|
||||
if (absolute && !/^https?:\/\//i.test(path)) {
|
||||
throw new ScriptError(
|
||||
'config',
|
||||
`Služba ${target.serviceId} nemá základní adresu, skript musí volat absolutní URL.`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = absolute ? path : joinUrl(target.baseUrl, path);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(joinUrl(connection.baseUrl, path));
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
|
||||
throw new ScriptError('config', `Neplatná adresa: ${raw}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(options?.query ?? {})) {
|
||||
@@ -108,7 +118,7 @@ function transportError(err: unknown, url: URL): ScriptError {
|
||||
}
|
||||
|
||||
export interface CreateHttpOptions {
|
||||
connection: ResolvedConnection;
|
||||
target: ResolvedTarget;
|
||||
signal: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
|
||||
@@ -118,7 +128,7 @@ export interface CreateHttpOptions {
|
||||
}
|
||||
|
||||
export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
|
||||
const { target, signal, idempotencyKey, redact, log, onCall } = options;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
@@ -126,7 +136,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
body: unknown,
|
||||
httpOptions?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>> {
|
||||
const url = buildUrl(connection, path, httpOptions);
|
||||
const url = buildUrl(target, path, httpOptions);
|
||||
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
|
||||
const startedAt = Date.now();
|
||||
onCall();
|
||||
@@ -140,7 +150,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
Accept: 'application/json',
|
||||
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
...connection.headers,
|
||||
...target.headers,
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...httpOptions?.headers,
|
||||
},
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna.
|
||||
*/
|
||||
|
||||
import type { ConnectorOperation, ProvidedField } from '../data/connectors.js';
|
||||
import type { OperationField } from '../data/connectors.js';
|
||||
import type { OperationField, ProvidedField, ServiceOperation } from '../data/services.js';
|
||||
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
|
||||
|
||||
/** `idoklad.get-issued-invoice` -> `idoklad` */
|
||||
export function connectorIdOf(scriptId: string): string {
|
||||
export function serviceIdOf(scriptId: string): string {
|
||||
return scriptId.slice(0, scriptId.indexOf('.'));
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ function toOperationField(field: ScriptField): OperationField {
|
||||
*/
|
||||
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
|
||||
return {
|
||||
id: `${connectorIdOf(scriptId)}.${field.id}`,
|
||||
id: `${serviceIdOf(scriptId)}.${field.id}`,
|
||||
name: field.id,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
@@ -89,7 +88,7 @@ function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
|
||||
}
|
||||
|
||||
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
|
||||
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
|
||||
export function toServiceOperation(manifest: ScriptManifest): ServiceOperation {
|
||||
return {
|
||||
id: operationIdOf(manifest.id),
|
||||
name: manifest.name,
|
||||
|
||||
+13
-13
@@ -17,8 +17,8 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { config } from '../config.js';
|
||||
import { connectors, setScriptActions, type ConnectorOperation } from '../data/connectors.js';
|
||||
import { connectorIdOf, parseManifest, toConnectorOperation } from './manifest.js';
|
||||
import { services, setScriptActions, type ServiceOperation } from '../data/services.js';
|
||||
import { parseManifest, serviceIdOf, toServiceOperation } from './manifest.js';
|
||||
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
|
||||
|
||||
export type ScriptRunFn = (
|
||||
@@ -150,7 +150,7 @@ async function scan(): Promise<void> {
|
||||
problems.set(id, {
|
||||
file: fileName,
|
||||
scriptId: null,
|
||||
message: 'Název souboru musí mít tvar konektor.operace.js, jen malá písmena a pomlčky.',
|
||||
message: 'Název souboru musí mít tvar sluzba.operace.js, jen malá písmena a pomlčky.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -196,30 +196,30 @@ async function scan(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prenese nactene skripty do katalogu konektoru.
|
||||
* Prenese nactene skripty do katalogu sluzeb.
|
||||
*
|
||||
* Tim se naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku
|
||||
* videt, i v sablonach - vsechno se uz pta pres `findOperation`.
|
||||
*/
|
||||
function publishToCatalog(): void {
|
||||
const byConnector = new Map<string, ConnectorOperation[]>();
|
||||
const byService = new Map<string, ServiceOperation[]>();
|
||||
|
||||
for (const script of scripts.values()) {
|
||||
const connectorId = connectorIdOf(script.manifest.id);
|
||||
if (!connectors.some((connector) => connector.id === connectorId)) {
|
||||
const serviceId = serviceIdOf(script.manifest.id);
|
||||
if (!services.some((service) => service.id === serviceId)) {
|
||||
problems.set(script.manifest.id, {
|
||||
file: script.file,
|
||||
scriptId: script.manifest.id,
|
||||
message: `Konektor ${connectorId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
|
||||
message: `Služba ${serviceId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const list = byConnector.get(connectorId) ?? [];
|
||||
list.push(toConnectorOperation(script.manifest));
|
||||
byConnector.set(connectorId, list);
|
||||
const list = byService.get(serviceId) ?? [];
|
||||
list.push(toServiceOperation(script.manifest));
|
||||
byService.set(serviceId, list);
|
||||
}
|
||||
|
||||
setScriptActions(byConnector);
|
||||
setScriptActions(byService);
|
||||
}
|
||||
|
||||
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
|
||||
@@ -280,7 +280,7 @@ export type SaveResult =
|
||||
*/
|
||||
export async function saveSource(id: string, code: string): Promise<SaveResult> {
|
||||
if (!isValidScriptId(id)) {
|
||||
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.' };
|
||||
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je sluzba.operace.' };
|
||||
}
|
||||
if (code.trim().length === 0) {
|
||||
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
|
||||
|
||||
+15
-8
@@ -12,9 +12,10 @@
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { connectionSecrets, resolveConnection } from './connections.js';
|
||||
import type { Connector } from '../data/connectorStore.js';
|
||||
import { resolveTarget, targetSecrets } from './connections.js';
|
||||
import { createHttp } from './http.js';
|
||||
import { connectorIdOf } from './manifest.js';
|
||||
import { serviceIdOf } from './manifest.js';
|
||||
import { getScript } from './registry.js';
|
||||
import {
|
||||
isRetryableKind,
|
||||
@@ -28,6 +29,12 @@ import { createRedactor, describe, scriptUtil } from './util.js';
|
||||
import { validateValues } from './values.js';
|
||||
|
||||
export interface RunScriptOptions {
|
||||
/**
|
||||
* Konektor firmy, pod jehoz udaji se to zavola. `null` je v poradku jen
|
||||
* u obecnych sluzeb - u ostatnich se skript nespusti a rekne se to nahlas.
|
||||
* Prislusnost konektoru k firme si overuje volajici.
|
||||
*/
|
||||
connector?: Connector | null;
|
||||
/**
|
||||
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
|
||||
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
|
||||
@@ -95,16 +102,16 @@ export async function runScript(
|
||||
}
|
||||
|
||||
const { manifest } = script;
|
||||
const connection = resolveConnection(connectorIdOf(scriptId));
|
||||
const redact = createRedactor(connectionSecrets(connection));
|
||||
const target = resolveTarget(serviceIdOf(scriptId), options.connector ?? null);
|
||||
const redact = createRedactor(targetSecrets(target));
|
||||
|
||||
if (!connection.ready) {
|
||||
if (!target.ready) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'config',
|
||||
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
|
||||
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
@@ -144,7 +151,7 @@ export async function runScript(
|
||||
|
||||
const ctx: ScriptContext = {
|
||||
http: createHttp({
|
||||
connection,
|
||||
target,
|
||||
signal: controller.signal,
|
||||
idempotencyKey,
|
||||
redact,
|
||||
@@ -155,7 +162,7 @@ export async function runScript(
|
||||
}),
|
||||
util: scriptUtil,
|
||||
log,
|
||||
config: Object.freeze({ ...connection.config }),
|
||||
config: Object.freeze({ ...target.serviceConfig }),
|
||||
idempotencyKey,
|
||||
fail(message, detail) {
|
||||
throw new ScriptError('terminal', message, { detail: describe(detail) });
|
||||
|
||||
@@ -76,7 +76,7 @@ export const scriptManifestSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,
|
||||
'ID skriptu musí mít tvar konektor.operace, například idoklad.get-issued-invoice.',
|
||||
'ID skriptu musí mít tvar sluzba.operace, například idoklad.get-issued-invoice.',
|
||||
),
|
||||
name: z.string().min(1, 'Název skriptu nesmí být prázdný.'),
|
||||
description: z.string().min(1, 'Popis skriptu nesmí být prázdný.'),
|
||||
|
||||
Reference in New Issue
Block a user