Rozdeleni na sluzby a konektory, pristupove udaje do konektoru

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

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

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

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

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

Overeno: npm run typecheck prochazi na serveru i webu.

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