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
+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,