From 751b7f24d760f9cc519735248ff3ff4f19d0b45d Mon Sep 17 00:00:00 2001 From: AV77web Date: Sun, 7 Jun 2026 18:34:46 +0200 Subject: [PATCH] modifiche a workflow n8n --- src/app/api/articoli/ricerca-prezzi/route.ts | 50 ++++++ src/components/ArticleModal.tsx | 87 +++++++++- src/components/PriceSearchPanel.tsx | 162 ++++++++++++++++++ src/hooks/usePriceSearch.ts | 82 +++++++++ src/lib/permissions.ts | 4 + src/lib/price-search/index.ts | 15 ++ src/lib/price-search/n8n.ts | 79 +++++++++ src/lib/price-search/normalize.ts | 48 ++++++ src/types/priceSearch.ts | 28 +++ ...-prezzi => magricambi-ricerca-prezzi.json} | 65 ++----- 10 files changed, 569 insertions(+), 51 deletions(-) create mode 100644 src/app/api/articoli/ricerca-prezzi/route.ts create mode 100644 src/components/PriceSearchPanel.tsx create mode 100644 src/hooks/usePriceSearch.ts create mode 100644 src/lib/price-search/index.ts create mode 100644 src/lib/price-search/n8n.ts create mode 100644 src/lib/price-search/normalize.ts create mode 100644 src/types/priceSearch.ts rename webhook/{magricambi-ricerca-prezzi => magricambi-ricerca-prezzi.json} (60%) diff --git a/src/app/api/articoli/ricerca-prezzi/route.ts b/src/app/api/articoli/ricerca-prezzi/route.ts new file mode 100644 index 0000000..7cda019 --- /dev/null +++ b/src/app/api/articoli/ricerca-prezzi/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/src/lib/auth/requireSession"; +import { searchPriceOffers } from "@/src/lib/price-search"; +import type { PriceSearchRequest } from "@/src/types/priceSearch"; + +export async function POST(request: Request) { + const session = await requirePermission( + (p) => p.canSearchPrices, + "Non hai i permessi per la ricerca prezzi", + ); + if (session instanceof NextResponse) return session; + + try { + const body = (await request.json()) as Partial; + + const descrizione = String(body.descrizione ?? "").trim(); + if (!descrizione) { + return NextResponse.json( + { error: "La descrizione e obbligatoria per la ricerca" }, + { status: 400 }, + ); + } + + const payload: PriceSearchRequest = { + descrizione, + barcode: body.barcode ?? null, + codice: body.codice ?? null, + quantita: + body.quantita !== undefined && body.quantita !== null + ? Number(body.quantita) + : undefined, + }; + + const result = await searchPriceOffers(payload, { + userId: session.user.id, + userName: session.user.utente || session.user.name || session.user.email || undefined, + }); + + return NextResponse.json(result); + } catch (error) { + console.error("[API ricerca-prezzi]", error); + const message = + error instanceof Error ? error.message : "Errore ricerca prezzi"; + + const status = message.includes("non configurata") ? 503 : 502; + + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/components/ArticleModal.tsx b/src/components/ArticleModal.tsx index c289718..e8c28df 100644 --- a/src/components/ArticleModal.tsx +++ b/src/components/ArticleModal.tsx @@ -1,6 +1,9 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import PriceSearchPanel from "@/src/components/PriceSearchPanel"; +import { usePermissions } from "@/src/contexts/PermissionsContext"; +import { usePriceSearch } from "@/src/hooks/usePriceSearch"; import type { Article, ArticleInput, ArticleFamily } from "@/src/types/article"; import type { Stabilimento, Magazzino } from "@/src/types/stabilimento"; @@ -19,6 +22,9 @@ export default function ArticleModal({ }: ArticleModalProps) { const isEdit = article !== null; const modalRef = useRef(null); + const { permissions } = usePermissions(); + const { result, error: searchError, search, cancel, reset, isLoading } = + usePriceSearch(); const [formData, setFormData] = useState({ codice: "", @@ -173,11 +179,40 @@ export default function ArticleModal({ setSelectedMagazzino(value); }; + const handlePriceSearch = async () => { + const descrizione = formData.descrizione.trim(); + if (!descrizione) { + setError("Inserisci una descrizione prima di cercare i prezzi"); + return; + } + + setError(null); + await search({ + descrizione, + barcode: formData.barcode || null, + codice: formData.codice || null, + }); + }; + + const handleApplyPrice = (price: number) => { + setFormData((prev) => ({ ...prev, prezzo: price })); + }; + + const showPriceSearchPanel = + permissions.canSearchPrices && + (isLoading || searchError !== null || result !== null); + + useEffect(() => { + return () => reset(); + }, [reset]); + return (
{/* Header */}
@@ -299,6 +334,56 @@ export default function ArticleModal({
+ {permissions.canSearchPrices && ( +
+ +
+ )} + + {permissions.canSearchPrices && ( + + )} + {/* Giacenza iniziale (solo per nuovo articolo) */} {!isEdit && (
diff --git a/src/components/PriceSearchPanel.tsx b/src/components/PriceSearchPanel.tsx new file mode 100644 index 0000000..7498644 --- /dev/null +++ b/src/components/PriceSearchPanel.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch"; + +const CONFIDENCE_LABELS: Record = { + high: "Alta", + medium: "Media", + low: "Bassa", +}; + +const CONFIDENCE_STYLES: Record = { + high: "bg-green-500/15 text-green-400 border-green-500/30", + medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30", + low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", +}; + +function formatPrice(price: number | null, currency: string) { + if (price === null) return "N/D"; + return new Intl.NumberFormat("it-IT", { + style: "currency", + currency, + }).format(price); +} + +interface PriceSearchPanelProps { + isLoading: boolean; + error: string | null; + result: PriceSearchResponse | null; + onApplyPrice: (price: number) => void; + onCancel: () => void; +} + +export default function PriceSearchPanel({ + isLoading, + error, + result, + onApplyPrice, + onCancel, +}: PriceSearchPanelProps) { + if (!isLoading && !error && !result) return null; + + return ( +
+
+

+ + + + Ricerca prezzi online +

+ {isLoading && ( + + )} +
+ + {isLoading && ( +
+ + + + + Ricerca in corso… può richiedere fino a 30 secondi +
+ )} + + {error && ( +
+ {error} +
+ )} + + {result && ( + <> +

+ Query: {result.query} +

+ + {result.offers.length === 0 ? ( +

Nessuna offerta trovata.

+ ) : ( +
    + {result.offers.map((offer, index) => ( +
  • +
    +
    +
    + + {offer.vendor} + + + {CONFIDENCE_LABELS[offer.confidence]} + +
    +

    {offer.title}

    + {offer.shippingNote && ( +

    {offer.shippingNote}

    + )} +
    +
    + + {formatPrice(offer.price, offer.currency)} + +
    + + Apri + + {offer.price !== null && ( + + )} +
    +
    +
    +
  • + ))} +
+ )} + +

{result.disclaimer}

+ + )} +
+ ); +} diff --git a/src/hooks/usePriceSearch.ts b/src/hooks/usePriceSearch.ts new file mode 100644 index 0000000..07d7c7a --- /dev/null +++ b/src/hooks/usePriceSearch.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; + +type SearchStatus = "idle" | "loading" | "success" | "error"; + +export function usePriceSearch() { + const [status, setStatus] = useState("idle"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const search = useCallback(async (input: PriceSearchRequest) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setStatus("loading"); + setError(null); + setResult(null); + + try { + const res = await fetch("/api/articoli/ricerca-prezzi", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + signal: controller.signal, + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : "Errore ricerca prezzi", + ); + } + + const response = data as PriceSearchResponse; + setResult(response); + setStatus("success"); + return response; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + setStatus("idle"); + return null; + } + + const message = err instanceof Error ? err.message : "Errore sconosciuto"; + setError(message); + setStatus("error"); + return null; + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + } + }, []); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStatus("idle"); + }, []); + + const reset = useCallback(() => { + cancel(); + setResult(null); + setError(null); + }, [cancel]); + + return { + status, + result, + error, + search, + cancel, + reset, + isLoading: status === "loading", + }; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 84c197a..3661ea9 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -37,6 +37,7 @@ export interface AppPermissions { canAssegnaArticoliMacchina: boolean; canEditMacchine: boolean; canRemoveArticoloMacchina: boolean; + canSearchPrices: boolean; } export function getPermissions(ruolo: UserRole): AppPermissions { @@ -49,6 +50,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: true, canEditMacchine: true, canRemoveArticoloMacchina: true, + canSearchPrices: true, }; case "tecnico": return { @@ -58,6 +60,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: true, canEditMacchine: true, canRemoveArticoloMacchina: true, + canSearchPrices: true, }; case "operatore": return { @@ -67,6 +70,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: false, canEditMacchine: false, canRemoveArticoloMacchina: false, + canSearchPrices: false, }; } } diff --git a/src/lib/price-search/index.ts b/src/lib/price-search/index.ts new file mode 100644 index 0000000..7545c2e --- /dev/null +++ b/src/lib/price-search/index.ts @@ -0,0 +1,15 @@ +import { searchPriceOffersViaN8n } from "@/src/lib/price-search/n8n"; +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; + +export async function searchPriceOffers( + input: PriceSearchRequest, + meta?: { userId?: string; userName?: string }, +): Promise { + const backend = (process.env.PRICE_SEARCH_BACKEND ?? "n8n").toLowerCase(); + + if (backend === "n8n") { + return searchPriceOffersViaN8n(input, meta); + } + + throw new Error(`Backend ricerca prezzi non supportato: ${backend}`); +} diff --git a/src/lib/price-search/n8n.ts b/src/lib/price-search/n8n.ts new file mode 100644 index 0000000..b860eb7 --- /dev/null +++ b/src/lib/price-search/n8n.ts @@ -0,0 +1,79 @@ +import { + DEFAULT_DISCLAIMER, + isPriceSearchResponse, + parseN8nResponseBody, +} from "@/src/lib/price-search/normalize"; +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; + +function getWebhookConfig() { + const url = + process.env.N8N_PRICE_SEARCH_WEBHOOK_URL?.trim() ?? + process.env.N8N_PRICE_WEBHOOK_URL?.trim(); + const secret = process.env.N8N_PRICE_WEBHOOK_SECRET?.trim(); + const timeoutMs = Number(process.env.N8N_PRICE_WEBHOOK_TIMEOUT_MS ?? 45_000); + + if (!url || !secret) { + throw new Error("Ricerca prezzi n8n non configurata (URL o secret mancanti)"); + } + + return { url, secret, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 45_000 }; +} + +export async function searchPriceOffersViaN8n( + input: PriceSearchRequest, + meta?: { userId?: string; userName?: string }, +): Promise { + const { url, secret, timeoutMs } = getWebhookConfig(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Secret": secret, + }, + body: JSON.stringify({ + ...input, + meta: { + source: "magricambi", + userId: meta?.userId ?? null, + userName: meta?.userName ?? null, + requestedAt: new Date().toISOString(), + }, + }), + signal: controller.signal, + cache: "no-store", + }); + + const text = await res.text(); + const data = parseN8nResponseBody(text, res.status); + + if (!res.ok) { + const err = + data && typeof data === "object" && "error" in data + ? String((data as { error: unknown }).error) + : `Errore n8n (HTTP ${res.status})`; + throw new Error(err); + } + + if (!isPriceSearchResponse(data)) { + throw new Error( + "Formato risposta n8n non conforme. Verifica il nodo Respond OK del workflow.", + ); + } + + return data; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Timeout ricerca prezzi: riprova tra poco"); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export { DEFAULT_DISCLAIMER }; diff --git a/src/lib/price-search/normalize.ts b/src/lib/price-search/normalize.ts new file mode 100644 index 0000000..b28892a --- /dev/null +++ b/src/lib/price-search/normalize.ts @@ -0,0 +1,48 @@ +import type { PriceSearchResponse } from "@/src/types/priceSearch"; + +const DEFAULT_DISCLAIMER = + "Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto."; + +export function isPriceSearchResponse(value: unknown): value is PriceSearchResponse { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.query === "string" && + typeof v.searchedAt === "string" && + Array.isArray(v.offers) && + typeof v.disclaimer === "string" + ); +} + +export function emptyPriceSearchResponse(query: string, disclaimer?: string): PriceSearchResponse { + return { + query, + searchedAt: new Date().toISOString(), + offers: [], + disclaimer: disclaimer ?? DEFAULT_DISCLAIMER, + source: "tavily", + }; +} + +export function parseN8nResponseBody(text: string, status: number): unknown { + const trimmed = text.trim(); + + if (!trimmed) { + if (status === 200) { + throw new Error( + "Workflow n8n senza risposta. Verifica che il workflow sia attivo e che l'URL sia quello di produzione.", + ); + } + throw new Error(`Errore n8n (HTTP ${status})`); + } + + try { + return JSON.parse(trimmed); + } catch { + throw new Error( + `Risposta non valida dal workflow n8n (HTTP ${status}). Controlla l'esecuzione su n8n.`, + ); + } +} + +export { DEFAULT_DISCLAIMER }; diff --git a/src/types/priceSearch.ts b/src/types/priceSearch.ts new file mode 100644 index 0000000..21dd0c8 --- /dev/null +++ b/src/types/priceSearch.ts @@ -0,0 +1,28 @@ +export type PriceOfferConfidence = "high" | "medium" | "low"; +export type PriceOfferSource = "page" | "snippet" | "estimate"; + +export interface PriceOffer { + vendor: string; + title: string; + price: number | null; + currency: "EUR"; + url: string; + shippingNote?: string; + confidence: PriceOfferConfidence; + source: PriceOfferSource; +} + +export interface PriceSearchRequest { + descrizione: string; + barcode?: string | null; + codice?: string | null; + quantita?: number; +} + +export interface PriceSearchResponse { + query: string; + searchedAt: string; + offers: PriceOffer[]; + disclaimer: string; + source?: "tavily" | "llm"; +} diff --git a/webhook/magricambi-ricerca-prezzi b/webhook/magricambi-ricerca-prezzi.json similarity index 60% rename from webhook/magricambi-ricerca-prezzi rename to webhook/magricambi-ricerca-prezzi.json index 6d9b55d..8a6c687 100644 --- a/webhook/magricambi-ricerca-prezzi +++ b/webhook/magricambi-ricerca-prezzi.json @@ -3,15 +3,15 @@ "nodes": [ { "parameters": { - "content": "## MagRicambi — Ricerca prezzi\n\n**Setup:**\n1. Credenziale Header Auth sul Webhook (`X-Webhook-Secret`)\n2. Credenziale Tavily su nodo HTTP\n3. Credenziale OpenAI\n4. Attiva workflow → copia Production URL in `N8N_PRICE_SEARCH_WEBHOOK_URL`\n\n**Chiamata da MagRicambi:**\n`POST /api/articoli/ricerca-prezzi` → proxy verso questo webhook", - "height": 320, - "width": 420 + "content": "## MagRicambi — Ricerca prezzi v3 (solo Tavily)\n\n**Affidabile, senza LLM.** Estrae i prezzi dagli snippet web di Tavily.\n\n**Setup:**\n1. Credenziale Header Auth sul Webhook (`X-Webhook-Secret`)\n2. Credenziale Tavily (header `Authorization: Bearer tvly-...`)\n3. **Attiva** il workflow (toggle verde)\n4. Copia **Production URL** in `N8N_PRICE_SEARCH_WEBHOOK_URL`\n\n**Flusso:** Webhook → Tavily → Estrai prezzi → Respond OK\n\nRisponde **sempre** JSON valido per MagRicambi.", + "height": 340, + "width": 440 }, "id": "sticky-note-setup", "name": "Note Setup", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, - "position": [-80, 80] + "position": [-80, 60] }, { "parameters": { @@ -93,6 +93,7 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [960, 200], + "onError": "continueRegularOutput", "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_CREDENTIAL_ID", @@ -102,38 +103,13 @@ }, { "parameters": { - "method": "POST", - "url": "https://api.openai.com/v1/chat/completions", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "openAiApi", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={\n \"model\": \"gpt-4o-mini\",\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Sei un assistente per acquisti industriali. Ricevi risultati di ricerca web. Estrai SOLO offerte con prezzo esplicito nei testi forniti. Se il prezzo non è chiaro usa price null e confidence low. Rispondi SOLO con JSON valido nel formato: {\\\"offers\\\":[{\\\"vendor\\\":\\\"\\\",\\\"title\\\":\\\"\\\",\\\"price\\\":null,\\\"currency\\\":\\\"EUR\\\",\\\"url\\\":\\\"\\\",\\\"shippingNote\\\":\\\"\\\",\\\"confidence\\\":\\\"high|medium|low\\\",\\\"source\\\":\\\"snippet|page|estimate\\\"}]}. Massimo 8 offerte. Valuta EUR. Nessun testo fuori dal JSON.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Query: \" + $('Build Query').first().json.query + \"\\n\\nRisultati ricerca:\\n\" + JSON.stringify($json.results ?? $json)\n }\n ]\n}", - "options": { - "timeout": 25000 - } + "jsCode": "const query = $('Build Query').first().json.query;\nconst descrizione = $('Build Query').first().json.descrizione;\n\nconst DISCLAIMER =\n \"Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto.\";\nconst TAVILY_ERROR_DISCLAIMER =\n DISCLAIMER + \" Ricerca web non disponibile al momento: riprovare tra poco.\";\n\nfunction hostnameFromUrl(url) {\n try {\n return new URL(url).hostname.replace(/^www\\./, '');\n } catch {\n return 'Sconosciuto';\n }\n}\n\nfunction extractPriceFromText(text) {\n const patterns = [\n /(?:€|EUR)\\s*(\\d{1,6}[.,]\\d{2})/i,\n /(\\d{1,6}[.,]\\d{2})\\s*(?:€|EUR)/i,\n ];\n for (const re of patterns) {\n const m = text.match(re);\n if (m) {\n const n = Number(m[1].replace(',', '.'));\n if (Number.isFinite(n) && n > 0 && n < 100000) return n;\n }\n }\n return null;\n}\n\nconst tavilyItem = $input.first().json;\nconst tavilyFailed = Boolean(tavilyItem?.error);\nconst results = Array.isArray(tavilyItem?.results) ? tavilyItem.results : [];\n\nconst offers = [];\nconst seenUrls = new Set();\n\nfor (const r of results) {\n const url = String(r.url ?? '').trim();\n if (!url || url === '#' || seenUrls.has(url)) continue;\n\n const text = `${r.title ?? ''} ${r.content ?? ''}`;\n const price = extractPriceFromText(text);\n if (price === null) continue;\n\n seenUrls.add(url);\n offers.push({\n vendor: hostnameFromUrl(url).slice(0, 120),\n title: String(r.title ?? descrizione).slice(0, 200),\n price,\n currency: 'EUR',\n url,\n confidence: 'medium',\n source: 'snippet',\n });\n\n if (offers.length >= 8) break;\n}\n\nreturn [{\n json: {\n query,\n searchedAt: new Date().toISOString(),\n offers,\n disclaimer: tavilyFailed && offers.length === 0 ? TAVILY_ERROR_DISCLAIMER : DISCLAIMER,\n source: 'tavily',\n },\n}];" }, - "id": "http-openai-extract", - "name": "OpenAI Extract", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [1200, 200], - "credentials": { - "openAiApi": { - "id": "REPLACE_WITH_CREDENTIAL_ID", - "name": "OpenAI API" - } - } - }, - { - "parameters": { - "jsCode": "const query = $('Build Query').first().json.query;\nconst descrizione = $('Build Query').first().json.descrizione;\n\nlet parsed = { offers: [] };\n\ntry {\n const item = $input.first().json;\n const raw =\n item.choices?.[0]?.message?.content ??\n item.message?.content ??\n item.text ??\n '{}';\n const clean = String(raw).replace(/```json|```/g, '').trim();\n parsed = JSON.parse(clean);\n} catch (e) {\n parsed = { offers: [] };\n}\n\nconst offers = (parsed.offers ?? [])\n .map((o) => ({\n vendor: String(o.vendor ?? 'Sconosciuto').slice(0, 120),\n title: String(o.title ?? descrizione).slice(0, 200),\n price:\n o.price === null || o.price === undefined || o.price === ''\n ? null\n : Number(o.price),\n currency: 'EUR',\n url: String(o.url ?? ''),\n shippingNote: o.shippingNote ? String(o.shippingNote) : undefined,\n confidence: ['high', 'medium', 'low'].includes(o.confidence)\n ? o.confidence\n : 'low',\n source: ['page', 'snippet', 'estimate'].includes(o.source)\n ? o.source\n : 'snippet',\n }))\n .filter((o) => o.url && o.url !== '#');\n\nreturn [{\n json: {\n query,\n searchedAt: new Date().toISOString(),\n offers,\n disclaimer:\n 'Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell\\'acquisto.',\n },\n}];" - }, - "id": "code-format-response", - "name": "Format Response", + "id": "code-extract-prices", + "name": "Estrai Prezzi", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1440, 200] + "position": [1200, 200] }, { "parameters": { @@ -147,7 +123,7 @@ "name": "Respond OK", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, - "position": [1680, 200] + "position": [1440, 200] }, { "parameters": { @@ -209,25 +185,14 @@ "main": [ [ { - "node": "OpenAI Extract", + "node": "Estrai Prezzi", "type": "main", "index": 0 } ] ] }, - "OpenAI Extract": { - "main": [ - [ - { - "node": "Format Response", - "type": "main", - "index": 0 - } - ] - ] - }, - "Format Response": { + "Estrai Prezzi": { "main": [ [ { @@ -256,11 +221,11 @@ } ], "triggerCount": 1, - "updatedAt": "2026-06-04T12:00:00.000Z", - "versionId": "magricambi-ricerca-prezzi-v1", + "updatedAt": "2026-06-07T18:00:00.000Z", + "versionId": "magricambi-ricerca-prezzi-v3-tavily", "meta": { "templateCredsSetupCompleted": false, "instanceId": "magricambi-local" }, "active": false -} \ No newline at end of file +}