modifiche a workflow n8n
All checks were successful
Deploy to VPS / build (push) Successful in 41s
Deploy to VPS / deploy (push) Successful in 31s

This commit is contained in:
AV77web 2026-06-07 18:34:46 +02:00
parent 5475816d0b
commit 751b7f24d7
10 changed files with 569 additions and 51 deletions

View file

@ -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<PriceSearchRequest>;
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 });
}
}

View file

@ -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<HTMLDivElement>(null);
const { permissions } = usePermissions();
const { result, error: searchError, search, cancel, reset, isLoading } =
usePriceSearch();
const [formData, setFormData] = useState<ArticleInput>({
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div
ref={modalRef}
className="w-full max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col"
className={`w-full bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col ${
showPriceSearchPanel ? "max-w-2xl" : "max-w-lg"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
@ -299,6 +334,56 @@ export default function ArticleModal({
</div>
</div>
{permissions.canSearchPrices && (
<div className="flex justify-end">
<button
type="button"
onClick={handlePriceSearch}
disabled={isLoading || !formData.descrizione.trim()}
className="px-4 py-2 rounded-lg bg-blue-600/20 border border-blue-500/40 text-blue-400 text-sm font-medium hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
Cerca prezzi online
</button>
</div>
)}
{permissions.canSearchPrices && (
<PriceSearchPanel
isLoading={isLoading}
error={searchError}
result={result}
onApplyPrice={handleApplyPrice}
onCancel={cancel}
/>
)}
{/* Giacenza iniziale (solo per nuovo articolo) */}
{!isEdit && (
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">

View file

@ -0,0 +1,162 @@
"use client";
import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch";
const CONFIDENCE_LABELS: Record<PriceOffer["confidence"], string> = {
high: "Alta",
medium: "Media",
low: "Bassa",
};
const CONFIDENCE_STYLES: Record<PriceOffer["confidence"], string> = {
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 (
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/30 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-blue-400 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Ricerca prezzi online
</h3>
{isLoading && (
<button
type="button"
onClick={onCancel}
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
Annulla
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-3 text-sm text-zinc-400">
<svg className="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Ricerca in corso può richiedere fino a 30 secondi
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-400 text-sm">
{error}
</div>
)}
{result && (
<>
<p className="text-xs text-zinc-500">
Query: <span className="text-zinc-400">{result.query}</span>
</p>
{result.offers.length === 0 ? (
<p className="text-sm text-zinc-400">Nessuna offerta trovata.</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{result.offers.map((offer, index) => (
<li
key={`${offer.vendor}-${offer.url}-${index}`}
className="p-3 rounded-lg bg-zinc-800/80 border border-zinc-700"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">
{offer.vendor}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${CONFIDENCE_STYLES[offer.confidence]}`}
>
{CONFIDENCE_LABELS[offer.confidence]}
</span>
</div>
<p className="text-xs text-zinc-400 mt-0.5 line-clamp-2">{offer.title}</p>
{offer.shippingNote && (
<p className="text-[11px] text-zinc-500 mt-1">{offer.shippingNote}</p>
)}
</div>
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<span className="text-sm font-semibold text-green-400">
{formatPrice(offer.price, offer.currency)}
</span>
<div className="flex items-center gap-2">
<a
href={offer.url}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
Apri
</a>
{offer.price !== null && (
<button
type="button"
onClick={() => onApplyPrice(offer.price as number)}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
>
Applica
</button>
)}
</div>
</div>
</div>
</li>
))}
</ul>
)}
<p className="text-[11px] text-zinc-500 leading-relaxed">{result.disclaimer}</p>
</>
)}
</div>
);
}

View file

@ -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<SearchStatus>("idle");
const [result, setResult] = useState<PriceSearchResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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",
};
}

View file

@ -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,
};
}
}

View file

@ -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<PriceSearchResponse> {
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}`);
}

View file

@ -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<PriceSearchResponse> {
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 };

View file

@ -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<string, unknown>;
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 };

28
src/types/priceSearch.ts Normal file
View file

@ -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";
}

View file

@ -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
}
}