U posledniho volani je videt, co presne prislo

Prvni verze seznamu poslednich volani ukazovala cas a duvod odmitnuti, ale ne
data. To je k nicemu: "parametr data ma mit typ text" nerekne, co tedy prislo,
a bez toho zbyva hadat.

Argument, ze se telo nema drzet kvuli zakaznickym datum, navic neobstal.
Tickety si cela prijata tela u udalosti drzi uz davno, takze si to jedno misto
zakazovalo neco, co aplikace jinde bezne dela - a zaplatilo za to celym uzitkem
te funkce.

Radek volani se ted rozklikne na dve veci:

- rozpad po parametrech: jmeno, cesta, co se cekalo a co na te ceste opravdu
  bylo, vcetne nahledu hodnoty. Sklada ho readPayload, protoze jen tam je videt
  kontrakt i telo naraz - pozdeji uz se kontrakt muze zmenit
- cele prijate telo, vcetne klicu, ktere odesilatel posila navic a kontrakt
  o nich nevi. Delsi nez 8 kB se usekne

Telo se do zaznamu zmrazi na retezec, aby se pozdeji nezmenilo pod rukama,
a serializace je v try - cyklicka struktura nesmi shodit prijem webhooku.

Overeno na bezici instanci s vlastnim DATA_DIR, telem s data jako textem misto
objektu a bez povinneho status:

  ok     callSid  | cekame string, povinny | prislo text = CA-B
  CHYBA  status   | cekame string, povinny | prislo neprislo
  CHYBA  data     | cekame object          | prislo text = tohle je text misto objektu

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-02 10:46:36 +02:00
co-authored by Claude Opus 5
parent e8bb4d6e54
commit 40875439e4
6 changed files with 250 additions and 35 deletions
+42 -3
View File
@@ -170,6 +170,22 @@ export interface Automation {
issues: string[];
}
/** Jak dopadl jeden deklarovany parametr v jednom volani. */
export interface WebhookCallField {
name: string;
/** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */
path: string;
required: boolean;
/** Deklarovany typ z kontraktu. */
type: string;
/** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */
got: string;
/** Kratky nahled prijate hodnoty. */
preview: string;
/** false = tenhle parametr je duvod, proc se volani odmitlo. */
ok: boolean;
}
/**
* Jedno volani webhooku, jak dopadlo.
*
@@ -177,9 +193,10 @@ export interface Automation {
* 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.
* Drzi se **cele prijate telo i rozpad po parametrech**. Duvod odmitnuti sam
* nestaci: "parametr data ma mit typ text" nerekne, co tedy prislo, a bez toho
* zbyva hadat. Tickety si cela prijata tela u udalosti drzi uz davno, takze to
* neni novy druh dat, jen se konecne daji videt i u volani, ktera neprosla.
*/
export interface WebhookCall {
at: string;
@@ -189,6 +206,28 @@ export interface WebhookCall {
problems: string[];
/** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */
runId: string | null;
/** Kazdy deklarovany parametr a jak dopadl. */
fields: WebhookCallField[];
/** Cele prijate telo jako JSON. Delsi nez `MAX_BODY` se usekne. */
body: string;
/** true = telo bylo delsi a je useknute. */
truncated: boolean;
}
/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */
const MAX_BODY = 8_000;
/** Telo do zaznamu: zmrazene na retezec, aby se pozdeji nezmenilo pod rukama. */
export function bodyForCall(body: unknown): { body: string; truncated: boolean } {
let text: string;
try {
text = JSON.stringify(body, null, 2) ?? '';
} catch {
// Cyklicka struktura nebo neco, co JSON neumi. Radeji nic nez pad.
text = '';
}
if (text.length <= MAX_BODY) return { body: text, truncated: false };
return { body: text.slice(0, MAX_BODY), truncated: true };
}
/** Kolik poslednich volani se u automatizace drzi. */
+48 -4
View File
@@ -1,9 +1,11 @@
import { Router } from 'express';
import { z } from 'zod';
import {
bodyForCall,
findByWebhookToken,
recordWebhookCall,
type TriggerField,
type WebhookCallField,
} from '../data/automationStore.js';
import type { FieldType } from '../data/conditions.js';
import { findByIntakeToken } from '../data/tenants.js';
@@ -191,7 +193,8 @@ webhookRouter.post('/:token', (req, res) => {
}
const body = (req.body ?? {}) as Record<string, unknown>;
const { values, problems } = readPayload(automation.flow.trigger.fields, body);
const { values, problems, report } = readPayload(automation.flow.trigger.fields, body);
const received = bodyForCall(body);
if (problems.length > 0) {
console.warn(`[webhook] ${automation.id}: neplatna data - ${problems.join(' ')}`);
@@ -206,6 +209,8 @@ webhookRouter.post('/:token', (req, res) => {
ok: false,
problems,
runId: null,
fields: report,
...received,
});
return res.status(400).json({
error: 'validation_error',
@@ -247,6 +252,8 @@ webhookRouter.post('/:token', (req, res) => {
ok: true,
problems: [],
runId: item?.id ?? null,
fields: report,
...received,
});
// 202: prevzato, zpracuje se. Ne 200, ktera by rikala "hotovo".
return res.status(202).json({
@@ -308,14 +315,33 @@ webhookRouter.get('/:token', (req, res) => {
function readPayload(
fields: TriggerField[],
body: Record<string, unknown>,
): { values: Record<string, unknown>; problems: string[] } {
): { values: Record<string, unknown>; problems: string[]; report: WebhookCallField[] } {
const values: Record<string, unknown> = {};
const problems: string[] = [];
/*
* Zprava o kazdem parametru, at uz dopadl jakkoliv. Duvod odmitnuti sam
* nestaci: "parametr data ma mit typ text" nerekne, co tedy prislo, a bez
* toho se hada. Sklada se tady, protoze jen tady je videt kontrakt i telo
* naraz - pozdeji uz se kontrakt muze zmenit.
*/
const report: WebhookCallField[] = [];
for (const field of fields) {
const value = getPath(body, field.path ?? field.name);
const path = field.path ?? field.name;
const value = getPath(body, path);
const entry: WebhookCallField = {
name: field.name,
path,
required: field.required,
type: field.type,
got: describeIncoming(value),
preview: previewValue(value),
ok: true,
};
report.push(entry);
if (value === undefined || value === null) {
entry.ok = !field.required;
if (field.required) {
const where = field.path && field.path !== field.name ? ` (cesta ${field.path})` : '';
problems.push(`Chybí povinný parametr "${field.name}"${where}.`);
@@ -324,6 +350,7 @@ function readPayload(
}
if (!matchesType(value, field.type)) {
entry.ok = false;
problems.push(`Parametr "${field.name}" má mít typ ${field.type}.`);
continue;
}
@@ -331,7 +358,24 @@ function readPayload(
values[field.name] = value;
}
return { values, problems };
return { values, problems, report };
}
/** Co na te ceste opravdu bylo. Bez toho se z hlasky o typu neda nic poznat. */
function describeIncoming(value: unknown): string {
if (value === undefined) return 'nepřišlo';
if (value === null) return 'null';
if (Array.isArray(value)) return 'seznam';
if (typeof value === 'object') return 'objekt';
return typeof value === 'string' ? 'text' : typeof value === 'number' ? 'číslo' : 'ano/ne';
}
/** Kratky nahled hodnoty do tabulky. Cele telo je u volani zvlast. */
function previewValue(value: unknown): string {
if (value === undefined) return '';
const text = typeof value === 'string' ? value : JSON.stringify(value);
if (typeof text !== 'string') return '';
return text.length > 80 ? `${text.slice(0, 80)}...` : text;
}
/** Ukazkove telo podle kontraktu, at je videt, jak to ma vypadat. */