Kontrakt webhooku: objekt jde vybrat a odmitnuti je videt

Parametr spoustece `data` byl deklarovany jako type string a povinny, zatimco
odesilatel ho posila jako objekt a v prvni zprave hovoru ho jeste nema. Kazde
volani proto skoncilo na 400 a automatizace hodinu nedelala nic.

Za tim byly tri veci, kazda sama o sobe malicherna:

- rucne pridany parametr byl vychozi povinny, zatimco parametr odvozeny
  z ukazkoveho tela nepovinny. Dve ruzna vychozi nastaveni pro tutez vec
  v jednom formulari. Nove je nepovinny i rucne pridany: povinny znamena
  "odmitni volani" a do toho nema nikdo spadnout omylem
- objekt a seznam neslo vybrat. declarableFieldTypes nabizel jen string, number,
  boolean a date, a TriggerConfig.tsx mel jeste treti kopii toho seznamu.
  Deklarovat data jako objekt tedy neslo, i kdyz matchesType objekt umi
  a operatorsByType pro nej ma operatory
- odmitnuti nebylo nikde videt. Skoncilo jako console.warn v logu kontejneru:
  zadna udalost, zadny beh, nic na detailu automatizace

Ten treti bod je ten podstatny. Chybu v kontraktu udela ten, kdo ho psal, ale
400 dostane odesilatel - a ten s tim nic nenadela, casto je to cizi sluzba,
ktera volani neopakuje. Majitel automatizace se nedozvi nic a v portalu vypada
vsechno v poradku.

Detail automatizace proto ukazuje poslednich deset volani: cas, jestli proslo
nebo ne, a u odmitnutych duvod. Telo se schvalne neuklada, duvod uz rika, co je
spatne, a drzet payloady by znamenalo mit v pameti kopie zakaznickych dat.
Seznam je v pameti, restart ho zahodi. Incident se z toho nezaklada
a upozorneni se neposila: staci radek, implementator se ozve sam.

Vzorova automatizace ma data opravene na object a nepovinne.

Do navrhu 25 jsou zapsana rozhodnuti z diskuze: "moje tickety" jsou tickety
prirazene mne, v helpdesku ty, ktere jsem zalozil ja, a helpdeskove pozadavky
vidi lide podle teze hierarchie jako tickety. Sekce 6 popisuje tuhle zmenu.

Overeno na bezici instanci s vlastnim DATA_DIR: telo s data jako objektem
projde, prvni zprava hovoru s data null projde, telo bez callSid se dal odmita,
a vsechna tri jsou videt v seznamu poslednich volani.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-02 10:14:48 +02:00
co-authored by Claude Opus 5
parent 72da07debe
commit e8bb4d6e54
8 changed files with 302 additions and 41 deletions
+78 -27
View File
@@ -1,8 +1,9 @@
# 25 - Navrh: pristupny portal a viditelnost
**Navrh, ne popis stavu. Nic z toho zatim neni naprogramovane.** Az se cast
udela, prepise se do prislusneho souboru dokumentace a odsud zmizi. Stejne
pravidlo jako u [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
**Navrh, ne popis stavu.** Jedina hotova cast je sekce 6, ostatni zatim
naprogramovane neni. Az se cast udela, prepise se do prislusneho souboru
dokumentace a odsud zmizi. Stejne pravidlo jako
u [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
Popis soucasneho stavu je v [01-prehled-a-stav.md](01-prehled-a-stav.md), prava
a pohledy v [07-firmy-a-prava.md](07-firmy-a-prava.md), widgety
@@ -390,6 +391,19 @@ jinak -> moje tickety
Posledni radka je rozhodnuti, ne odvozeni: bez ni nema vedouci co rozdelovat,
protoze neprirazeny ticket nepatri nikomu.
**"Moje" znamena prirazene mne.** Nic vic: ne zalozene mnou, ne komentovane
mnou. Ticket, ktery clovek zapsal po telefonu a predal dal, uz neni jeho -
prave o tom predani slo.
V **helpdesku** je to naopak: tam jsou "moje" ty pozadavky, ktere jsem **zalozil
ja**. Neni to vyjimka z pravidla, je to tentyz pohled z druhe strany. Zadavatel
pozadavek nikdy nema prirazeny, protoze ho resi nekdo jiny, a helpdeskove
pozadavky se automaticky vazou na hlavni firmu.
Kdo pozadavky v helpdesku vidi, se ridi **toutez hierarchii** jako u ticketu:
firemni priznak, pak skupiny, jinak svoje. Helpdesk tedy nema vlastni pravidlo
viditelnosti, jen jinou definici toho, co je "moje".
### Past: dve identity
Projekt ma **uzivatele** (kdo se prihlasi) a **resitele** (na koho jde ticket),
@@ -474,6 +488,58 @@ nem neda nic overit do hloubky.
---
## 6 - Kdyz kontrakt nesedi na data (hotovo)
Jedina cast, ktera uz naprogramovana **je**. Je tady proto, ze patri ke stejne
tride problemu jako zbytek: konfigurace je formalne v poradku a pritom nesedi na
skutecna data.
### Jak to vypadalo
Parametr spoustece `data` byl deklarovany jako `type: string, required: true`,
zatimco odesilatel ho posila jako objekt a v prvni zprave hovoru ho jeste nema.
Kazde volani proto skoncilo na 400 a automatizace hodinu nedelala nic.
Za tim byly tri veci a kazda z nich sama o sobe malicherna:
1. **Rucne pridany parametr byl vychozi povinny**, zatimco parametr odvozeny
z ukazkoveho tela nepovinny. Dve ruzna vychozi nastaveni pro tutez vec
v jednom formulari.
2. **Objekt a seznam neslo vybrat.** `declarableFieldTypes` je nabizel jen
`string`, `number`, `boolean` a `date`, a klient mel jeste svoji treti kopii
toho seznamu. Deklarovat `data` jako objekt tedy neslo, i kdyz `matchesType`
objekt umi a `operatorsByType` pro nej ma operatory.
3. **Odmitnuti nebylo nikde videt.** Skoncilo jako `console.warn` v logu
kontejneru: zadna udalost, zadny beh, nic na detailu automatizace.
Ten treti bod je ten podstatny. Chybu v kontraktu udela ten, kdo ho psal, ale
**400 dostane odesilatel** - a ten s tim nic nenadela, casto je to cizi sluzba,
ktera volani neopakuje. Majitel automatizace se nedozvi nic a v portalu vypada
vsechno v poradku.
### Co se zmenilo
- **Novy parametr je nepovinny.** Povinny znamena "odmitni volani" a do toho
nema nikdo spadnout omylem. Zaskrtnout to jde vzdycky.
- **Objekt a seznam jdou vybrat.** `declarableFieldTypes` je ma a klient si uz
nedrzi vlastni kopii, bere sdileny seznam.
- **Poslednich deset volani je videt na detailu automatizace.** Cas, jestli
proslo nebo ne, a u odmitnutych duvod.
Telo se **schvalne neuklada**. Duvod odmitnuti uz rika, co je spatne ("Parametr
data ma mit typ string"), a drzet payloady by znamenalo mit v pameti kopie
zakaznickych dat, aniz by o tom kdokoliv vedel. Seznam volani se drzi v pameti,
restart ho zahodi - je to diagnostika posledni hodiny, ne historie.
### Co se tim nedela
Nezaklada se incident, neposila se upozorneni. Staci radek: kdyz se
implementator ozve, ze mu neco nesedi, je tady videt co. Rozsirit se to da
pozdeji, ale kazda dalsi vrstva stoji za to az ve chvili, kdy tenhle seznam
prestane stacit.
---
## Poradi praci
1. **Formularove primitivy** a rozdeleni dvou formularu (ticket, helpdesk). Nic
@@ -500,34 +566,19 @@ Postgres kdykoliv mezi tim, nezavisle na ostatnim.
- **Strankovani seznamu ticketu** zmeni chovani dnesniho klientskeho hledani. Musi
jit ruku v ruce se serverovym hledanim, ne pred nim.
## Rozhodnuto
- **"Moje tickety" jsou tickety prirazene mne.** V helpdesku ty, ktere jsem
zalozil ja. Podrobnosti v sekci 4, cast "Vypocet stropu".
- **Helpdeskove pozadavky vidi lide podle teze hierarchie** jako tickety, tedy
firemni priznak, pak skupiny, jinak svoje.
- **Odmitnuta volani webhooku maji byt videt na automatizaci**, staci radek
s casem a duvodem. Uz je to hotove, viz sekce 6.
## Otevrene otazky
U kazde jde o rozhodnuti, ktere se z modelu neda odvodit.
### A - Co znamena "moje tickety" pro strop
Tri odpovedi, lisi se v tom, kdy clovek o ticket prijde z dohledu:
| Varianta | Dusledek |
| ---------------------------------- | --------------------------------------------------------------- |
| jen prirazene mne | pracovnik zalozi ticket po telefonu, preda ho a hned o nem nevi |
| prirazene plus zalozene mnou | vidi i to, co poslal dal |
| prirazene, zalozene i komentovane | vidi vse, ceho se dotkl |
Ticket dnes nenese, kdo ho zalozil rucne (`automationId` je jen u automatickych),
takze druha a treti varianta znamenaji nove pole na ticketu.
### B - Helpdesk a strop
Firma A posle pozadavek firme B. Zadavatel u firmy A ho vidi pres
`helpdeskSourceId`, i kdyz ticket vlastni firma B. Zadavatel neni resitel a neni
v zadne skupine, takze strop na nej nesedi.
Nabizi se, ze helpdeskovy pohled ma vlastni pravidlo ("vidim, co moje firma
poslala") a strop se na nej nevztahuje. Otazka je, **kdo z firmy A to vidi**:
kazdy clen firmy, nebo jen ten, kdo pozadavek poslal? U firmy o peti lidech je
odpoved jina nez u firmy o padesati.
### C - Hledani v udalostech
Udalosti nesou cele prijate telo webhooku. Hledat v nem znamena hledat v datech,
+38
View File
@@ -2,6 +2,44 @@
Nejnovejsi nahore.
## 2026-09-02 - Kontrakt webhooku: objekt jde vybrat a odmitnuti je videt
Parametr spoustece `data` byl deklarovany jako `type: string, required: true`,
zatimco odesilatel ho posila jako objekt a v prvni zprave hovoru ho jeste nema.
Kazde volani proto skoncilo na 400 a automatizace hodinu nedelala nic.
Za tim byly tri veci, kazda sama o sobe malicherna:
- **Rucne pridany parametr byl vychozi povinny**, zatimco parametr odvozeny
z ukazkoveho tela nepovinny. Dve ruzna vychozi nastaveni pro tutez vec
v jednom formulari. Nove je nepovinny i rucne pridany: povinny znamena
"odmitni volani" a do toho nema nikdo spadnout omylem.
- **Objekt a seznam neslo vybrat.** `declarableFieldTypes` nabizel jen `string`,
`number`, `boolean` a `date`, a `TriggerConfig.tsx` mel jeste treti kopii toho
seznamu. Deklarovat `data` jako objekt tedy neslo, i kdyz `matchesType` objekt
umi a `operatorsByType` pro nej ma operatory. Ted jsou v nabidce oba a klient
si vlastni kopii nedrzi.
- **Odmitnuti nebylo nikde videt.** Skoncilo jako `console.warn` v logu
kontejneru: zadna udalost, zadny beh, nic na detailu automatizace.
Ten treti bod je ten podstatny. Chybu v kontraktu udela ten, kdo ho psal, ale
**400 dostane odesilatel** - a ten s tim nic nenadela, casto je to cizi sluzba,
ktera volani neopakuje. Majitel automatizace se nedozvi nic a v portalu vypada
vsechno v poradku.
Detail automatizace proto ukazuje **poslednich deset volani**: cas, jestli
proslo nebo ne, a u odmitnutych duvod. Telo se schvalne neuklada, duvod uz rika,
co je spatne, a drzet payloady by znamenalo mit v pameti kopie zakaznickych dat.
Seznam je v pameti, restart ho zahodi - je to diagnostika posledni hodiny, ne
historie. Incident se z toho nezaklada a upozorneni se neposila: staci radek,
protoze implementator se ozve sam a tady je videt co.
Vzorova automatizace ma `data` opravene na `type: 'object', required: false`.
Overeno na bezici instanci s vlastnim DATA_DIR: telo s `data` jako objektem
projde, prvni zprava hovoru s `data: null` projde, telo bez `callSid` se dal
odmita - a vsechna tri jsou videt v seznamu poslednich volani.
## 2026-09-02 - Navrh: pristupny portal a viditelnost
Novy [25-navrh-pristupny-portal.md](25-navrh-pristupny-portal.md). Je to navrh,
+50 -1
View File
@@ -170,8 +170,54 @@ export interface Automation {
issues: string[];
}
/**
* Jedno volani webhooku, jak dopadlo.
*
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru, kam se nikdo
* nedostane. Odesilatel dostal 400 a vedel o tom, ale ten, kdo kontrakt napsal,
* se nedozvedel nic - automatizace svitila zelene a jen do ni nic nechodilo.
*
* Telo se **schvalne neuklada**. Duvod odmitnuti uz rika, co je spatne
* ("Parametr data ma mit typ string"), a drzet payloady by znamenalo mit
* v pameti kopie zakaznickych dat bez toho, aby o tom kdokoliv vedel.
*/
export interface WebhookCall {
at: string;
/** true = prevzato do fronty, false = odmitnuto pro neplatna data. */
ok: boolean;
/** Duvody odmitnuti. Prazdne u prevzatych volani. */
problems: string[];
/** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */
runId: string | null;
}
/** Kolik poslednich volani se u automatizace drzi. */
const MAX_CALLS = 10;
const calls = new Map<string, WebhookCall[]>();
/** Zapise, jak dopadlo jedno volani webhooku. Nejnovejsi je prvni. */
export function recordWebhookCall(automationId: string, call: WebhookCall): void {
const list = calls.get(automationId) ?? [];
list.unshift(call);
if (list.length > MAX_CALLS) list.length = MAX_CALLS;
calls.set(automationId, list);
}
/** Poslednich par volani. Cte se jen pres detail automatizace, ktery hlida firmu. */
export function recentWebhookCalls(automationId: string): WebhookCall[] {
return calls.get(automationId) ?? [];
}
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
/**
* Poslednich `MAX_CALLS` volani webhooku, nejnovejsi prvni.
*
* Drzi se v pameti, restart je zahodi. Je to diagnostika posledni hodiny,
* ne historie - na tu jsou behy.
*/
recentCalls: WebhookCall[];
/**
* Model prichozich dat odvozeny z ukazky u spoustece.
*
@@ -1001,7 +1047,9 @@ function seedRealAutomations(): void {
{ id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true },
{ id: 'f_mtjqv4qj_1', name: 'result', type: 'string', required: false, path: 'data.result' },
{ id: 'f_mtjqv4zn_2', name: 'rating', type: 'string', required: false, path: 'data.rating' },
{ id: 'f_mtjqvws7_3', name: 'data', type: 'string', required: true, path: 'data' },
// Objekt a nepovinne: telo ho posila jako strukturu a prvni zprava
// hovoru ho jeste nema. Deklarace `string` a povinny odmitala oboji.
{ id: 'f_mtjqvws7_3', name: 'data', type: 'object', required: false, path: 'data' },
],
webhookToken: config.seedWebhookToken || generateWebhookToken(),
},
@@ -1101,6 +1149,7 @@ function toDetail(stored: StoredAutomation): AutomationDetail {
model: stored.flow.trigger?.sample === undefined
? []
: describeModel(stored.flow.trigger.sample),
recentCalls: recentWebhookCalls(stored.id),
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
};
+17 -2
View File
@@ -35,8 +35,23 @@ export const fieldTypes: FieldType[] = [
'list',
];
/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */
export const declarableFieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
/**
* Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece.
*
* Objekt a seznam tady driv nebyly, protoze podminka se nad nimi zeptat skoro
* nema na co. Jenze parametr neni jen podklad pro podminku: kdyz odesilatel
* posle `data` jako objekt, musi jit deklarovat objekt, jinak ho kontrakt
* odmitne. Bez toho zbyval jediny "spravny" postup - nedeklarovat ho vubec
* a sahat na nej cestou, coz nikoho nenapadne.
*/
export const declarableFieldTypes: FieldType[] = [
'string',
'number',
'boolean',
'date',
'object',
'list',
];
/** Ktere operatory maji smysl pro ktery typ. */
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
+25 -1
View File
@@ -1,6 +1,10 @@
import { Router } from 'express';
import { z } from 'zod';
import { findByWebhookToken, type TriggerField } from '../data/automationStore.js';
import {
findByWebhookToken,
recordWebhookCall,
type TriggerField,
} from '../data/automationStore.js';
import type { FieldType } from '../data/conditions.js';
import { findByIntakeToken } from '../data/tenants.js';
import { intakeEvent } from '../data/ticketStore.js';
@@ -191,6 +195,18 @@ webhookRouter.post('/:token', (req, res) => {
if (problems.length > 0) {
console.warn(`[webhook] ${automation.id}: neplatna data - ${problems.join(' ')}`);
/*
* Odmitnuti musi byt videt v portalu, ne jen v logu kontejneru. Chybu
* v kontraktu udela ten, kdo ho psal, ale 400 dostane odesilatel - a ten
* s tim nic nenadela. Bez tohohle zaznamu se majitel automatizace nedozvi,
* ze uz hodinu nic nechodi, protoze v portalu vypada vsechno v poradku.
*/
recordWebhookCall(automation.id, {
at: new Date().toISOString(),
ok: false,
problems,
runId: null,
});
return res.status(400).json({
error: 'validation_error',
message: problems[0],
@@ -224,6 +240,14 @@ webhookRouter.post('/:token', (req, res) => {
console.info(
`[webhook] ${automation.id}: prijato, zarazeno jako ${item?.id ?? '(duplicita)'}`,
);
// I prevzata volani, aby v prehledu bylo videt "tohle proslo, tohle ne"
// a ne jen seznam chyb bez meritka.
recordWebhookCall(automation.id, {
at: new Date().toISOString(),
ok: true,
problems: [],
runId: item?.id ?? null,
});
// 202: prevzato, zpracuje se. Ne 200, ktera by rikala "hotovo".
return res.status(202).json({
accepted: true,
@@ -12,15 +12,19 @@ import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
import type {
Service,
FieldType,
FlowTrigger,
ModelNode,
TriggerField,
import {
declarableFieldTypes,
type Service,
type FieldType,
type FlowTrigger,
type ModelNode,
type TriggerField,
} from '@/types/dashboard';
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
// Nabidka typu se bere ze sdileneho seznamu. Driv tu byla vlastni kopie a ta
// se rozesla: objekt a seznam v ni chybely, takze `data` sla deklarovat jen
// jako text a kontrakt pak odmital kazde volani.
const fieldTypes: readonly FieldType[] = declarableFieldTypes;
/**
* Nastaveni spoustece: registrovana adresa webhooku a deklarace parametru,
@@ -101,8 +105,14 @@ function CustomFields({
editable: boolean;
onChange: (fields: TriggerField[]) => void;
}) {
/*
* Novy parametr je **nepovinny**. Povinny znamena "odmitni volani", a do toho
* nema nikdo spadnout omylem - zaskrtnout to jde vzdycky. Odvozeni parametru
* z ukazkoveho tela to tak delalo uz driv, rucni pridani ne, a ta nesrovnalost
* stala jedno odpoledne hledani, proc webhook prestal chodit.
*/
function addField() {
onChange([...fields, { id: newFieldId(), name: '', type: 'string', required: true }]);
onChange([...fields, { id: newFieldId(), name: '', type: 'string', required: false }]);
}
function updateField(id: string, patch: Partial<TriggerField>) {
@@ -28,6 +28,7 @@ import type {
AutomationFlow,
ServiceCatalog,
TriggerField,
WebhookCall,
} from '@/types/dashboard';
/** Kam se ma vlozit dalsi krok - null znamena, ze vyber neni otevreny. */
@@ -442,6 +443,10 @@ export default function AutomationDetail() {
</div>
)}
{flow.trigger?.serviceId === 'webhook' && (
<WebhookCalls calls={automation.data?.recentCalls ?? []} />
)}
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
<Info className="mt-0.5 size-3.5 shrink-0" />
Prototyp: nastavení jednotlivých polí kroku (mapování dat, filtry) zatím
@@ -467,6 +472,55 @@ export default function AutomationDetail() {
);
}
/**
* Poslednich deset volani webhooku.
*
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru. Odesilatel
* dostal 400 a vedel o tom, ale ten, kdo kontrakt psal, se nedozvedel nic -
* automatizace svitila zelene a jen do ni prestalo chodit. Tohle je to nejmensi,
* co staci: kdyz se implementator ozve, ze mu neco nesedi, je tady videt co.
*
* Telo se neuklada, jen duvod odmitnuti. Ten uz rika, co je spatne.
*/
function WebhookCalls({ calls }: { calls: WebhookCall[] }) {
return (
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Poslední volání</h2>
{calls.length === 0 ? (
<p className="mt-3 text-sm text-white/40">
Zatím nic nepřišlo. Objeví se tu deset posledních volání včetně těch,
která jsme odmítli.
</p>
) : (
<ul className="mt-3 space-y-2 text-sm">
{calls.map((call) => (
<li key={`${call.at}-${call.runId ?? 'x'}`} className="flex gap-2">
{call.ok ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="mt-0.5 size-3.5 shrink-0 text-rose-400" />
)}
<div className="min-w-0">
<span className="text-white/70">{formatDateTime(call.at)}</span>
{call.problems.length > 0 && (
<p className="text-xs leading-relaxed text-rose-300/80">
{call.problems.join(' ')}
</p>
)}
</div>
</li>
))}
</ul>
)}
<p className="mt-3 text-xs text-white/35">
Drží se v paměti, restart je zahodí. Na historii jsou běhy.
</p>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-3">
+22 -2
View File
@@ -506,8 +506,18 @@ export interface ServiceCatalog {
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */
export const declarableFieldTypes = ['string', 'number', 'boolean', 'date'] as const;
/**
* Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece.
* Objekt a seznam jsou mezi nimi kvuli telu, kde `data` prijde jako objekt.
*/
export const declarableFieldTypes = [
'string',
'number',
'boolean',
'date',
'object',
'list',
] as const;
export type ConditionOperator =
| 'eq'
@@ -599,10 +609,20 @@ export interface AutomationFlow {
steps: FlowStep[];
}
/** Jedno volani webhooku, jak dopadlo. Telo se neuklada, jen duvod odmitnuti. */
export interface WebhookCall {
at: string;
ok: boolean;
problems: string[];
runId: string | null;
}
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
/** Model prichozich dat z ukazky u spoustece. Pocita server pri cteni. */
model: ModelNode[];
/** Poslednich deset volani webhooku, nejnovejsi prvni. Drzi se v pameti. */
recentCalls: WebhookCall[];
createdAt: string;
updatedAt: string;
}