implementazione 3 di n8n
This commit is contained in:
parent
b7fd15ffcd
commit
9faf49acfa
11 changed files with 524 additions and 2 deletions
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
||||||
@echo-off
|
@echo-off
|
||||||
cd /d D:\maga\magricambi
|
cd /d D:\maga\magricambi2
|
||||||
git status
|
git status
|
||||||
echo.
|
echo.
|
||||||
set /p MSG=Messaggio commit:
|
set /p MSG=Messaggio commit:
|
||||||
|
|
|
||||||
51
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
51
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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 è 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 286 KiB |
|
|
@ -1,6 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
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 { Article, ArticleInput, ArticleFamily } from "@/src/types/article";
|
||||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||||
|
|
||||||
|
|
@ -19,6 +22,9 @@ export default function ArticleModal({
|
||||||
}: ArticleModalProps) {
|
}: ArticleModalProps) {
|
||||||
const isEdit = article !== null;
|
const isEdit = article !== null;
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { permissions } = usePermissions();
|
||||||
|
const { result, error: searchError, search, cancel, reset, isLoading } =
|
||||||
|
usePriceSearch();
|
||||||
|
|
||||||
const [formData, setFormData] = useState<ArticleInput>({
|
const [formData, setFormData] = useState<ArticleInput>({
|
||||||
codice: "",
|
codice: "",
|
||||||
|
|
@ -173,11 +179,36 @@ export default function ArticleModal({
|
||||||
setSelectedMagazzino(value);
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
|
@ -299,6 +330,65 @@ export default function ArticleModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{permissions.canSearchPrices && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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>
|
||||||
|
{result && !isLoading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
Chiudi risultati
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions.canSearchPrices && (
|
||||||
|
<PriceSearchPanel
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={searchError}
|
||||||
|
result={result}
|
||||||
|
onApplyPrice={handleApplyPrice}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Giacenza iniziale (solo per nuovo articolo) */}
|
{/* Giacenza iniziale (solo per nuovo articolo) */}
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">
|
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">
|
||||||
|
|
|
||||||
162
src/components/PriceSearchPanel.tsx
Normal file
162
src/components/PriceSearchPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/hooks/usePriceSearch.ts
Normal file
82
src/hooks/usePriceSearch.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ export interface AppPermissions {
|
||||||
canAssegnaArticoliMacchina: boolean;
|
canAssegnaArticoliMacchina: boolean;
|
||||||
canEditMacchine: boolean;
|
canEditMacchine: boolean;
|
||||||
canRemoveArticoloMacchina: boolean;
|
canRemoveArticoloMacchina: boolean;
|
||||||
|
canSearchPrices: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPermissions(ruolo: UserRole): AppPermissions {
|
export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||||
|
|
@ -49,6 +50,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||||
canAssegnaArticoliMacchina: true,
|
canAssegnaArticoliMacchina: true,
|
||||||
canEditMacchine: true,
|
canEditMacchine: true,
|
||||||
canRemoveArticoloMacchina: true,
|
canRemoveArticoloMacchina: true,
|
||||||
|
canSearchPrices: true,
|
||||||
};
|
};
|
||||||
case "tecnico":
|
case "tecnico":
|
||||||
return {
|
return {
|
||||||
|
|
@ -58,6 +60,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||||
canAssegnaArticoliMacchina: true,
|
canAssegnaArticoliMacchina: true,
|
||||||
canEditMacchine: true,
|
canEditMacchine: true,
|
||||||
canRemoveArticoloMacchina: true,
|
canRemoveArticoloMacchina: true,
|
||||||
|
canSearchPrices: true,
|
||||||
};
|
};
|
||||||
case "operatore":
|
case "operatore":
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,6 +70,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||||
canAssegnaArticoliMacchina: false,
|
canAssegnaArticoliMacchina: false,
|
||||||
canEditMacchine: false,
|
canEditMacchine: false,
|
||||||
canRemoveArticoloMacchina: false,
|
canRemoveArticoloMacchina: false,
|
||||||
|
canSearchPrices: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/lib/price-search/index.ts
Normal file
15
src/lib/price-search/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||||
|
import { searchPriceOffersViaN8n } from "@/src/lib/price-search/n8n";
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
91
src/lib/price-search/n8n.ts
Normal file
91
src/lib/price-search/n8n.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||||
|
|
||||||
|
const DISCLAIMER =
|
||||||
|
"Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto.";
|
||||||
|
|
||||||
|
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 ?? 35_000);
|
||||||
|
|
||||||
|
if (!url || !secret) {
|
||||||
|
throw new Error("Ricerca prezzi n8n non configurata (URL o secret mancanti)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, secret, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 35_000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Risposta non valida dal workflow n8n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err =
|
||||||
|
data && typeof data === "object" && "error" in data
|
||||||
|
? String((data as { error: unknown }).error)
|
||||||
|
: `Errore n8n (${res.status})`;
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPriceSearchResponse(data)) {
|
||||||
|
throw new Error("Formato risposta n8n non conforme a PriceSearchResponse");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { DISCLAIMER };
|
||||||
27
src/types/priceSearch.ts
Normal file
27
src/types/priceSearch.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue