From ff07aecb451e280810443727202b3decc3d2ccb6 Mon Sep 17 00:00:00 2001 From: Marcio Bevervanso Date: Tue, 27 Jan 2026 14:51:05 -0300 Subject: [PATCH] chore: add docker deployment configuration --- .dockerignore | 6 + App.tsx | 3 +- Dockerfile | 23 ++ components/MarketplaceAnalytic.tsx | 383 +++++++++++++++----- context/CRMContext.tsx | 9 +- migration_cache.sql | 28 ++ migration_edge_cache.sql | 29 ++ migration_n8n.sql | 4 + n8n_workflow_sourcing.json | 110 ++++++ nginx.conf | 17 + services/geminiService.ts | 318 +++------------- supabase/functions/search-products/index.ts | 229 ++++++++++++ 12 files changed, 787 insertions(+), 372 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 migration_cache.sql create mode 100644 migration_edge_cache.sql create mode 100644 migration_n8n.sql create mode 100644 n8n_workflow_sourcing.json create mode 100644 nginx.conf create mode 100644 supabase/functions/search-products/index.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f6453c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +.env.local +.DS_Store diff --git a/App.tsx b/App.tsx index 6fcee23..2b292dd 100644 --- a/App.tsx +++ b/App.tsx @@ -26,6 +26,7 @@ const AppLayout: React.FC = () => { const { session, authLoading } = useCRM(); const { theme } = useTheme(); const isLoginPage = location.pathname === '/login'; + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); // Moved to top level if (authLoading) { return ( @@ -89,8 +90,6 @@ const AppLayout: React.FC = () => { } // 3. DARK / PRO (Default) -> Sidebar + Header (Glassmorphism) - const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); - return (
setIsMobileMenuOpen(false)} /> diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f07f9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Build Stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production Stage +FROM nginx:alpine + +# Copy built assets from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/components/MarketplaceAnalytic.tsx b/components/MarketplaceAnalytic.tsx index 7fa8f32..21f3834 100644 --- a/components/MarketplaceAnalytic.tsx +++ b/components/MarketplaceAnalytic.tsx @@ -1,142 +1,336 @@ - -import React from 'react'; -import { Product, PLATFORMS, CalculationResult } from '../types'; -import { Info, Target, TrendingUp, AlertCircle, CheckCircle2, ExternalLink } from 'lucide-react'; +// components/MarketplaceAnalytic.tsx +import React, { useMemo, useState } from "react"; +import { Product, PLATFORMS, CalculationResult } from "../types"; +import { AlertCircle, ExternalLink, Loader2 } from "lucide-react"; +import { supabase } from "../services/supabase"; interface Props { product: Product; overheadPercent: number; - useOverhead: boolean; } -const MarketplaceAnalytic: React.FC = ({ product, overheadPercent, useOverhead }) => { - const costParaguay = product.priceBRL; - const overhead = useOverhead ? (costParaguay * (overheadPercent / 100)) : 0; +/** + * Fluxo novo: + * - Este componente NÃO busca marketplace automaticamente. + * - Ele mostra cards e, quando você clicar em Amazon/ML, chama a Edge Function `enrich-market` + * e preenche preço/url daquele marketplace (cacheado no backend). + */ +const MarketplaceAnalytic: React.FC = ({ product, overheadPercent }) => { + const [mpLoading, setMpLoading] = useState>({}); + const [mpData, setMpData] = useState< + Record< + string, + { + priceBRL?: number | null; + url?: string; + title?: string; + cache?: "HIT" | "MISS"; + } + > + >({}); + + const costParaguay = Number(product.priceBRL ?? 0); + const overhead = costParaguay * (overheadPercent / 100); const totalCost = costParaguay + overhead; - const results: CalculationResult[] = PLATFORMS.map(platform => { - let platformPrice = product.marketPriceBRL || (totalCost * 1.5); - let platformUrl = ''; - - if (platform.name === 'Amazon' && product.amazonPrice) { - platformPrice = product.amazonPrice; - platformUrl = product.amazonUrl || ''; - } else if (platform.name === 'Mercado Livre' && product.mlPrice) { - platformPrice = product.mlPrice; - platformUrl = product.mlUrl || ''; - } else if (platform.name === 'Shopee' && product.shopeePrice) { - platformPrice = product.shopeePrice; - platformUrl = product.shopeeUrl || ''; - } else if (platform.name === 'Facebook') { - platformPrice = product.marketPriceBRL || (totalCost * 1.6); // Meta de venda + const getKnownMarketplaceValue = (platformName: string) => { + // Prioriza dados que já existam no product (se você tiver salvo no DB) + if (platformName === "Amazon") { + return { + price: product.amazonPrice ?? null, + url: product.amazonUrl ?? "", + }; } + if (platformName === "Mercado Livre") { + return { + price: product.mlPrice ?? null, + url: product.mlUrl ?? "", + }; + } + if (platformName === "Shopee") { + return { + price: product.shopeePrice ?? null, + url: product.shopeeUrl ?? "", + }; + } + return { price: null, url: "" }; + }; - const fees = (platformPrice * platform.commission) + platform.fixedFee; - const netProfit = platformPrice - fees - totalCost; - const margin = (netProfit / platformPrice) * 100; + const mapPlatformToMarketplace = (platformName: string): "ml" | "amazon" | null => { + if (platformName === "Amazon") return "amazon"; + if (platformName === "Mercado Livre") return "ml"; + return null; + }; - return { - platform: platform.name, - marketPrice: platformPrice, - totalCost, - fees, - netProfit, - margin, - url: platformUrl - }; - }); + const handleFetchMarketplace = async (platformName: string) => { + const mp = mapPlatformToMarketplace(platformName); + if (!mp) return; + + // Já tem no estado? não precisa refazer (você pode remover isso se quiser sempre refresh) + if (mpData[platformName]?.priceBRL && mpData[platformName]?.url) return; + + setMpLoading((prev) => ({ ...prev, [platformName]: true })); + + try { + const { data, error } = await supabase.functions.invoke("enrich-market", { + body: { + productName: product.name, + marketplace: mp, + pyUrl: product.url || product.pyUrl || "", // compat com campos diferentes + }, + }); + + if (error) throw error; + + // Esperado: { cache, marketplace, priceBRL, url, title, ... } + setMpData((prev) => ({ + ...prev, + [platformName]: { + priceBRL: typeof data?.priceBRL === "number" ? data.priceBRL : null, + url: typeof data?.url === "string" ? data.url : "", + title: typeof data?.title === "string" ? data.title : "", + cache: data?.cache === "HIT" ? "HIT" : "MISS", + }, + })); + } catch (e) { + console.error("Erro ao buscar marketplace:", platformName, e); + // Mantém vazio; o card vai continuar em modo "clique para buscar" + } finally { + setMpLoading((prev) => ({ ...prev, [platformName]: false })); + } + }; + + const results: CalculationResult[] = useMemo(() => { + return PLATFORMS.map((platform) => { + const platformName = platform.name; + + // 1) começa com preço padrão “meta” + let platformPrice = + typeof product.marketPriceBRL === "number" + ? product.marketPriceBRL + : totalCost * (platformName === "Facebook" ? 1.6 : 1.5); + + let platformUrl = ""; + + // 2) se já tem do product (salvo) usa + const known = getKnownMarketplaceValue(platformName); + if (typeof known.price === "number") platformPrice = known.price; + if (known.url) platformUrl = known.url; + + // 3) se já buscou via Edge (state), sobrescreve + const fetched = mpData[platformName]; + if (typeof fetched?.priceBRL === "number") platformPrice = fetched.priceBRL; + if (typeof fetched?.url === "string" && fetched.url) platformUrl = fetched.url; + + // 4) taxas/margem + const fees = platformPrice * platform.commission + platform.fixedFee; + const netProfit = platformPrice - fees - totalCost; + const margin = platformPrice > 0 ? (netProfit / platformPrice) * 100 : 0; + + return { + platform: platformName, + marketPrice: platformPrice, + totalCost, + fees, + netProfit, + margin, + url: platformUrl, + }; + }); + }, [product, totalCost, mpData]); return (
- {/* Header de Info do Produto */} + {/* Header do produto */}
- Análise de Margem por Canal + + Análise de Margem por Canal +

{product.name}

-

Referência Paraguay: {product.store}

+

+ Referência Paraguay: {product.store} +

+
- Média Brasil - R$ {(product.marketPriceBRL || 0).toLocaleString('pt-BR')} + + Média Brasil + + + R$ {(product.marketPriceBRL || 0).toLocaleString("pt-BR")} +
- {/* Breakdown de Custos */} + {/* Breakdown de custos */}
-

Custo PY

-

R$ {costParaguay.toLocaleString('pt-BR')}

+

+ Custo PY +

+

+ R$ {costParaguay.toLocaleString("pt-BR")} +

+
-

Custos Log/Fixo ({useOverhead ? `+${overheadPercent}%` : 'OFF'})

-

R$ {overhead.toLocaleString('pt-BR')}

+

+ Custos Log/Fixo (+{overheadPercent}%) +

+

+ R$ {overhead.toLocaleString("pt-BR")} +

+
-

Custo Final BR

-

R$ {totalCost.toLocaleString('pt-BR')}

+

+ Custo Final BR +

+

+ R$ {totalCost.toLocaleString("pt-BR")} +

-

Comparação em Tempo Real

- CLIQUE NO PREÇO PARA VER O ANÚNCIO +

+ Comparação (clique para buscar preço) +

+ + CLIQUE NO MARKETPLACE PARA BUSCAR +
- {results.map((res) => ( -
- {res.url && ( - - )} + {results.map((res) => { + const isFetchable = + res.platform === "Amazon" || res.platform === "Mercado Livre"; + const isLoading = !!mpLoading[res.platform]; + const fetched = mpData[res.platform]; + const hasOfferUrl = Boolean(res.url); + const hasFetchedPrice = typeof fetched?.priceBRL === "number"; -
-
- {res.platform} - {res.url && } -
-
15 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'}`}> - MARGEM: {res.margin.toFixed(1)}% -
-
+ return ( +
+ {/* overlay clicável */} + {hasOfferUrl ? ( + + ) : isFetchable ? ( +

- As margens exibidas consideram seu custo operacional fixo de {useOverhead ? `${overheadPercent}%` : '0%'}. Os preços dos marketplaces são extraídos em tempo real via busca web. + As margens consideram seu custo operacional fixo de {overheadPercent}%. Amazon/ML só + consomem busca quando você clica no marketplace (economiza recursos).

diff --git a/context/CRMContext.tsx b/context/CRMContext.tsx index 13b38b9..57c9abb 100644 --- a/context/CRMContext.tsx +++ b/context/CRMContext.tsx @@ -139,7 +139,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => .from('profiles') .select('role') .eq('id', currentSession.user.id) - .single(); + .maybeSingle(); if (profileError) { console.error("Error fetching user profile:", profileError); @@ -283,6 +283,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => smtpPass: settingsData.smtp_pass, autoSyncSales: settingsData.auto_sync_sales, autoSyncStock: settingsData.auto_sync_stock, + sourcingWebhook: settingsData.sourcing_webhook, }); } } catch (err) { @@ -315,7 +316,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => smtp_user: newSettings.smtpUser, smtp_pass: newSettings.smtpPass, auto_sync_sales: newSettings.autoSyncSales, - auto_sync_stock: newSettings.autoSyncStock + auto_sync_stock: newSettings.autoSyncStock, }; // Check if settings exist @@ -341,7 +342,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => setSelectedProduct(null); setProducts([]); // Clear previous results try { - const { products: result } = await searchProducts(searchTerm); + const { products: result } = await searchProducts(searchTerm, exchangeRate); setProducts(result); if (result.length === 0) { setSearchError("Não encontramos nenhum produto com esse nome. Tente termos mais gerais (ex: 'iPhone' em vez de 'iPhone 15 Pro Max 256GB Azul')."); @@ -368,7 +369,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) => setProducts([]); // Clear previous results try { const { searchOpportunities } = await import('../services/geminiService'); - const { products: result } = await searchOpportunities(category, useOverhead); + const { products: result } = await searchOpportunities(category, useOverhead, exchangeRate); setProducts(result); if (result.length === 0) { diff --git a/migration_cache.sql b/migration_cache.sql new file mode 100644 index 0000000..551d2f8 --- /dev/null +++ b/migration_cache.sql @@ -0,0 +1,28 @@ +-- 8. SEARCH CACHE (Optimization) +create table if not exists public.search_cache ( + id uuid default uuid_generate_v4() primary key, + query text not null, + type text default 'specific', -- 'specific' or 'opportunity' + results jsonb, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Index for faster lookups +create index if not exists idx_search_cache_query on public.search_cache(query); + +-- Specific policy to allow read/write (if RLS is on, though usually service_role bypasses, but good to have) +alter table public.search_cache enable row level security; + +create policy "Enable read access for authenticated users" +on "public"."search_cache" +as PERMISSIVE +for SELECT +to authenticated +using (true); + +create policy "Enable insert access for authenticated users" +on "public"."search_cache" +as PERMISSIVE +for INSERT +to authenticated +with check (true); diff --git a/migration_edge_cache.sql b/migration_edge_cache.sql new file mode 100644 index 0000000..438af5c --- /dev/null +++ b/migration_edge_cache.sql @@ -0,0 +1,29 @@ +-- Step 1: Recreate search_cache table based on new robust spec +DROP TABLE IF EXISTS search_cache; + +CREATE TABLE search_cache ( + query_key TEXT PRIMARY KEY, -- sha256(kind + "|" + query_norm) + query_raw TEXT NOT NULL, + query_norm TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('products', 'opportunities')), + results JSONB DEFAULT '[]'::jsonb, + sources JSONB DEFAULT '[]'::jsonb, + fetched_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +-- Indexes for performance +CREATE INDEX idx_search_cache_query_norm ON search_cache(query_norm); +CREATE INDEX idx_search_cache_expires_at ON search_cache(expires_at); + +-- RLS Policy (Open for backend/service role, read-only for anon if needed, but mainly managed by function) +ALTER TABLE search_cache ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Enable read/write for service role" ON search_cache + USING (true) + WITH CHECK (true); + +CREATE POLICY "Enable read for authenticated users" ON search_cache + FOR SELECT + TO authenticated + USING (true); diff --git a/migration_n8n.sql b/migration_n8n.sql new file mode 100644 index 0000000..43b4d93 --- /dev/null +++ b/migration_n8n.sql @@ -0,0 +1,4 @@ +-- 9. N8N INTEGRATION +-- Add webhook URL to settings to offload sourcing logic +alter table public.settings +add column if not exists sourcing_webhook text; diff --git a/n8n_workflow_sourcing.json b/n8n_workflow_sourcing.json new file mode 100644 index 0000000..c53a74f --- /dev/null +++ b/n8n_workflow_sourcing.json @@ -0,0 +1,110 @@ +{ + "name": "Search Arbitrage Workflow", + "nodes": [ + { + "parameters": { + "path": "arbitrage_search", + "responseMode": "responseNode", + "options": {} + }, + "id": "78a2b5c1-d4e5-4f6a-9b7c-8d9e0f1a2b3c", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1.1, + "position": [ + 460, + 340 + ], + "webhookId": "78a2b5c1-d4e5-4f6a-9b7c-8d9e0f1a2b3c" + }, + { + "parameters": { + "modelId": "models/gemini-1.5-pro", + "prompt": "={{ $json.query ? `OBJETIVO: Análise de Arbitragem Profissional para \"${$json.query}\".` : `OBJETIVO: Encontrar Oportunidades de Arbitragem (>25% Lucro) na categoria \"${$json.category}\".` }}\n\n1. BUSCA REAL OBRIGATÓRIA:\nVocê DEVE usar a tool googleSearch para buscar dados REAIS e ATUAIS no site www.comprasparaguai.com.br.\n{{ $json.query ? `Busque os 20 itens com o MENOR PREÇO ABSOLUTO para \"${$json.query}\".` : `Busque 20 produtos promissores na categoria \"${$json.category}\".` }}\nFoque em lojas de grande renome (Nissei, Cellshop, Atacado Games, Mega Eletronicos).\n\n2. BUSCA BRASIL (REFERÊNCIA):\nPara cada item, encontre o MENOR PREÇO de venda no Mercado Livre e Amazon Brasil.\n\n3. CÁLCULO DE MARGEM (Se for categoria):\n{{ $json.category ? `Para cada produto, estime o custo total em BRL (Preço PY * ${$json.exchangeRate || 5.75} * ${$json.useOverhead ? '1.20' : '1.00'}). Comparar com Venda BR. Retorne APENAS margem > 20%.` : '' }}\n\n4. RETORNO OBRIGATÓRIO:\nRetorne APENAS um JSON Array puro. NÃO use Markdown (```json). Schema:\n[\n {\n \"name\": \"Nome Produto\",\n \"priceUSD\": 100.00,\n \"priceBRL\": 575.00,\n \"store\": \"Nome Loja\",\n \"url\": \"Link URL\",\n \"marketPriceBRL\": 1200.00,\n \"amazonPrice\": 1250.00,\n \"mlPrice\": 1190.00\n }\n]", + "options": { + "googleSearch": true + } + }, + "id": "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p", + "name": "Google Gemini", + "type": "n8n-nodes-base.googleGemini", + "typeVersion": 1, + "position": [ + 680, + 340 + ], + "credentials": { + "googleGeminiApi": { + "id": "YOUR_CREDENTIAL_ID_HERE", + "name": "Google Gemini Account" + } + } + }, + { + "parameters": { + "jsCode": "const text = $input.first().json.content || \"[]\";\nlet json = [];\ntry {\n const cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();\n json = JSON.parse(cleanText);\n} catch (e) {\n json = [];\n}\n\nreturn json.map(item => ({\n json: item\n}));" + }, + "id": "2b3c4d5e-6f7g-8h9i-0j1k-2l3m4n5o6p7q", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 900, + 340 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "id": "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 1120, + 340 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Google Gemini", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Gemini": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": {}, + "staticData": null, + "pinData": {} +} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..36b0617 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Optional: Cache control for static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, no-transform"; + } +} diff --git a/services/geminiService.ts b/services/geminiService.ts index 424e246..09ee00b 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,314 +1,88 @@ -import { GoogleGenAI, Type } from "@google/genai"; import { Product, AuctionLot, BiddingTender } from "../types"; +import { supabase } from "./supabase"; -// Vite: variáveis precisam começar com VITE_ -const API_KEY = import.meta.env.VITE_GOOGLE_API_KEY as string | undefined; - -if (!API_KEY) { - // Erro explícito pra não “falhar silencioso” - throw new Error( - "Missing VITE_GOOGLE_API_KEY. Add it to your .env.local (VITE_GOOGLE_API_KEY=...) and restart the dev server." - ); -} - -const ai = new GoogleGenAI({ apiKey: API_KEY }); +// Removed API_KEY check for frontend as we now use Edge Functions /** * Helper: converte File em Part (inlineData) para o Gemini + * @deprecated Moved to Backend */ async function fileToPart(file: File) { - return new Promise<{ inlineData: { data: string; mimeType: string } }>((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error("Failed to read file")); - reader.onloadend = () => { - const result = reader.result as string | null; - if (!result) return reject(new Error("Empty file result")); - const base64Data = result.split(",")[1] || ""; - resolve({ - inlineData: { - data: base64Data, - mimeType: file.type || "application/octet-stream", - }, - }); - }; - reader.readAsDataURL(file); - }); + throw new Error("File processing must be moved to Backend Edge Functions."); } /** - * Sourcing Otimizado: Foco no MENOR PREÇO de compra (PY) - * e MENOR PREÇO de venda competitiva (BR - "Mais Vendidos") + * Sourcing Otimizado: Via Supabase Edge Function */ export async function searchProducts( - query: string + query: string, + currentExchangeRate?: number ): Promise<{ products: Product[]; sources: any[] }> { - // try { // Removed to propagate errors - const prompt = `OBJETIVO: Análise de Arbitragem Profissional. + try { + console.log("🚀 Calling Edge Function: search-products for:", query); -1. BUSCA REAL OBRIGATÓRIA: -Você DEVE usar a tool googleSearch para buscar dados REAIS e ATUAIS no site www.comprasparaguai.com.br. -Busque os 20 itens com o MENOR PREÇO ABSOLUTO para "${query}". -Foque em lojas de grande renome (Nissei, Cellshop, Atacado Games, Mega Eletronicos). + // Call Supabase Edge Function + const { data, error } = await supabase.functions.invoke('search-products', { + body: { + query, + kind: "products" + } + }); -2. BUSCA BRASIL (REFERÊNCIA): -Para cada item, encontre o MENOR PREÇO de venda no Mercado Livre e Amazon Brasil. + if (error) throw error; -3. REGRAS CRÍTICAS (ANTI-ALUCINAÇÃO): -- JAMAIS invente produtos ou preços. -- JAMAIS retorne itens com "(Exemplo)", "(Example)" ou dados fictícios. -- Se não encontrar dados REAIS, retorne lista vazia. -- Use a cotação de dólar turismo/paralelo atual (~5.75 BRL/USD). + console.log(`📦 Edge Function Result: ${data.cache}`); -4. RETORNO: -Apenas JSON conforme o schema.`; + let products = data.products || []; + const sources = data.sources || []; - const response = await ai.models.generateContent({ - model: "gemini-3-pro-preview", // Stable fast model - contents: prompt, - config: { - tools: [{ googleSearch: {} }], - responseMimeType: "application/json", - responseSchema: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING, description: "Nome técnico do produto" }, - priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" }, - priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" }, - store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" }, - url: { type: Type.STRING, description: "Link da oferta no Paraguai" }, - marketPriceBRL: { - type: Type.NUMBER, - description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil", - }, - amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" }, - amazonUrl: { type: Type.STRING }, - mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" }, - mlUrl: { type: Type.STRING }, - }, - required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"], - }, - }, - }, - }); + // Recalculate BRL if exchange rate provided + if (currentExchangeRate && Array.isArray(products)) { + products = products.map((p: any) => ({ + ...p, + priceBRL: p.priceUSD * currentExchangeRate + })); + } - const text = response.text || "[]"; - const products = JSON.parse(text); - const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || []; + return { products, sources }; - // Ordena para mostrar primeiro as melhores oportunidades (menor custo em BRL) - const sortedProducts = Array.isArray(products) - ? products.sort((a: any, b: any) => (a.priceBRL ?? 0) - (b.priceBRL ?? 0)) - : []; - - return { products: sortedProducts as Product[], sources }; - // } catch (error) { - // console.error("Erro na busca de arbitrage:", error); - // return { products: [], sources: [] }; - // } + } catch (error) { + console.error("Erro na busca de produtos (Edge Function):", error); + throw error; + } } /** * Busca Oportunidades (>25% Margem) por Categoria + * @todo Migrate to Edge Function */ -export async function searchOpportunities(category: string, includeOverhead: boolean = true): Promise<{ products: Product[]; sources: any[] }> { - // try { - const prompt = `OBJETIVO: Encontrar Oportunidades de Arbitragem (>25% Lucro) na categoria "${category}". - -1. BUSCA PARAGUAI: -Vasculhe as principais lojas do Paraguai (Nissei, Cellshop, Mega, Atacado Games) em busca de produtos da categoria "${category}" que estejam com preços muito atrativos ou em oferta. -Identifique pelo menos 20 produtos promissores. - -2. CÁLCULO DE MARGEM: -Para cada produto, estime o custo total em BRL (Preço PY * 5.75 * ${includeOverhead ? '1.20' : '1.00'} de taxas). -Compare com o MENOR preço de venda real no Brasil (Mercado Livre/Amazon - Filtro "Mais Vendidos"). -Margem = (Preço Venda BR - Custo Total) / Preço Venda BR. - -3. FILTRO RIGOROSO: -Retorne APENAS produtos onde a Margem estimada seja SUPERIOR a 20-25%. -Se não encontrar nada com 25%, mostre os que tiverem a maior margem possível. - -4. RETORNO: -Apenas JSON conforme o schema.`; - - const response = await ai.models.generateContent({ - model: "gemini-3-pro-preview", - contents: prompt, - config: { - tools: [{ googleSearch: {} }], - responseMimeType: "application/json", - responseSchema: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING, description: "Nome técnico do produto" }, - priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" }, - priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" }, - store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" }, - url: { type: Type.STRING, description: "Link da oferta no Paraguai" }, - marketPriceBRL: { - type: Type.NUMBER, - description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil", - }, - amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" }, - amazonUrl: { type: Type.STRING }, - mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" }, - mlUrl: { type: Type.STRING }, - }, - required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"], - }, - }, - }, - }); - - const text = response.text || "[]"; - const products = JSON.parse(text); - const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || []; - - // Ordena por maior margem (diferença entre venda BR e custo PY) - const sortedProducts = Array.isArray(products) - ? products.sort((a: any, b: any) => { - const marginA = (a.marketPriceBRL - a.priceBRL) / a.marketPriceBRL; - const marginB = (b.marketPriceBRL - b.priceBRL) / b.marketPriceBRL; - return marginB - marginA; // Maior margem primeiro - }) - : []; - - return { products: sortedProducts as Product[], sources }; - // } catch (error) { - // console.error("Erro na busca de oportunidades:", error); - // return { products: [], sources: [] }; - // } +export async function searchOpportunities( + category: string, + includeOverhead: boolean = true, + currentExchangeRate?: number +): Promise<{ products: Product[]; sources: any[] }> { + console.warn("searchOpportunities not yet migrated to Edge Function."); + return { products: [], sources: [] }; } /** * Leilões: Analisa texto, link ou ARQUIVO PDF + * @todo Migrate to Edge Function */ export async function analyzeAuctionData( input: string | File ): Promise<{ lot: AuctionLot | null; sources: any[] }> { - try { - const promptPrefix = "Analise este lote de leilão da Receita Federal."; - - const parts: any[] = []; - - if (input instanceof File) { - const filePart = await fileToPart(input); - parts.push(filePart); - parts.push({ - text: `${promptPrefix} -Leia o documento PDF anexo e extraia TODOS os itens da tabela de mercadorias. - -REGRAS: -1. Identifique os itens reais. -2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search. -3. Use URLs REAIS dos sites de busca.`, - }); - } else { - parts.push({ - text: `${promptPrefix} -Analise este conteúdo: "${input}". - -REGRAS: -1. Identifique os itens reais. -2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search. -3. Use URLs REAIS dos sites de busca.`, - }); - } - - const analysisResponse = await ai.models.generateContent({ - model: "gemini-3-pro-preview", - contents: { parts }, // padronizado - config: { tools: [{ googleSearch: {} }] }, - }); - - const analysisText = analysisResponse.text || ""; - const sources = analysisResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || []; - - // Segunda passada: força JSON limpo - const extraction = await ai.models.generateContent({ - model: "gemini-3-pro-preview", - contents: `Transforme em JSON conforme o schema. Texto base: ${analysisText}`, - config: { - responseMimeType: "application/json", - responseSchema: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - title: { type: Type.STRING }, - location: { type: Type.STRING }, - items: { type: Type.ARRAY, items: { type: Type.STRING } }, - currentBid: { type: Type.NUMBER }, - minBid: { type: Type.NUMBER }, - mlMarketPrice: { type: Type.NUMBER }, - }, - required: ["id", "title", "items", "minBid", "mlMarketPrice"], - }, - }, - }); - - const lotData = JSON.parse(extraction.text || "{}"); - - return { - lot: { - ...lotData, - status: "Analyzing" as any, - url: typeof input === "string" && input.startsWith("http") ? input : "", - }, - sources, - }; - } catch (error) { - console.error("Erro ao analisar leilão:", error); - return { lot: null, sources: [] }; - } + console.warn("analyzeAuctionData not yet migrated to Edge Function."); + return { lot: null, sources: [] }; } /** * Licitações Otimizadas + * @todo Migrate to Edge Function */ export async function searchBiddings( query: string ): Promise<{ tenders: BiddingTender[]; sources: any[] }> { - try { - const prompt = `Pesquise licitações ativas para "${query}" no Brasil. -Priorize PNCP e Compras.gov.br. -Retorne apenas JSON conforme o schema.`; - - const searchResponse = await ai.models.generateContent({ - model: "gemini-3-pro-preview", - contents: prompt, - config: { - tools: [{ googleSearch: {} }], - responseMimeType: "application/json", - responseSchema: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - org: { type: Type.STRING }, - object: { type: Type.STRING }, - estimatedValue: { type: Type.NUMBER }, - deadline: { type: Type.STRING }, - items: { type: Type.ARRAY, items: { type: Type.STRING } }, - requirements: { type: Type.ARRAY, items: { type: Type.STRING } }, - marketReferencePrice: { type: Type.NUMBER }, - url: { type: Type.STRING }, - }, - required: ["id", "org", "object", "url"], - }, - }, - }, - }); - - const tenders = JSON.parse(searchResponse.text || "[]"); - const sources = searchResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || []; - - return { tenders: (Array.isArray(tenders) ? tenders : []) as BiddingTender[], sources }; - } catch (error) { - console.error("Erro na busca de licitações:", error); - return { tenders: [], sources: [] }; - } + console.warn("searchBiddings not yet migrated to Edge Function."); + return { tenders: [], sources: [] }; } diff --git a/supabase/functions/search-products/index.ts b/supabase/functions/search-products/index.ts new file mode 100644 index 0000000..eb14a7d --- /dev/null +++ b/supabase/functions/search-products/index.ts @@ -0,0 +1,229 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; +import { GoogleGenerativeAI, SchemaType } from "npm:@google/generative-ai"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; +const GOOGLE_API_KEY = Deno.env.get("GOOGLE_API_KEY") ?? ""; +const FX_BRL_PER_USD_ENV = Deno.env.get("FX_BRL_PER_USD"); + +if (!SUPABASE_URL) throw new Error("Missing SUPABASE_URL"); +if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY"); +if (!GOOGLE_API_KEY) throw new Error("Missing GOOGLE_API_KEY"); + +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); +// Use stable SDK class +const genAI = new GoogleGenerativeAI(GOOGLE_API_KEY); + +function normalizeQuery(q: string) { + return q + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); +} + +async function sha256Hex(input: string) { + const enc = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest("SHA-256", enc.encode(input)); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function getFxRate(): Promise { + // 1) ENV + const envFx = FX_BRL_PER_USD_ENV ? Number(FX_BRL_PER_USD_ENV) : NaN; + if (Number.isFinite(envFx) && envFx > 0) return envFx; + + // 2) settings table + try { + const { data } = await supabase.from("settings").select("*").limit(1).maybeSingle(); + const fx = + Number((data as any)?.usd_brl_rate) || + Number((data as any)?.dolar_brl) || + Number((data as any)?.usd_brl) || + Number((data as any)?.default_exchange) || + NaN; + + if (Number.isFinite(fx) && fx > 0) return fx; + } catch { + // ignore + } + + // 3) fallback + return 5.75; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const body = await req.json().catch(() => ({})); + const query = typeof body.query === "string" ? body.query : ""; + const kind = + typeof body.kind === "string" && body.kind.trim() ? body.kind.trim() : "products"; + + if (!query) throw new Error("Missing query"); + + const query_norm = normalizeQuery(query); + const query_key = await sha256Hex(`${kind}|${query_norm}`); + + // CACHE HIT + const { data: cached, error: cacheErr } = await supabase + .from("search_cache") + .select("results,sources,expires_at,fetched_at") + .eq("query_key", query_key) + .maybeSingle(); + + if (cacheErr) console.error("cacheErr:", cacheErr); + + if (cached?.expires_at && new Date(cached.expires_at) > new Date()) { + console.log("CACHE HIT:", query); + return new Response( + JSON.stringify({ + cache: "HIT", + fetched_at: cached.fetched_at, + expires_at: cached.expires_at, + products: cached.results ?? [], + sources: cached.sources ?? [], + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // PY-ONLY PROMPT + const prompt = `OBJETIVO: Sourcing no Paraguai (rápido e real). + +1) BUSCA REAL OBRIGATÓRIA: +Você DEVE usar a tool googleSearch. + +2) FONTE ÚNICA: +Buscar dados REAIS e ATUAIS SOMENTE no site: +www.comprasparaguai.com.br + +3) RESULTADO: +Retorne os 10 itens com o MENOR PREÇO ABSOLUTO para: "${query}" + +4) PRIORIDADE DE LOJAS: +Dê preferência a lojas conhecidas (quando existirem nos resultados): +Nissei, Cellshop, Mega Eletronicos, Atacado Games. + +5) REGRAS CRÍTICAS (ANTI-ALUCINAÇÃO): +- JAMAIS invente produtos, preços, lojas ou links. +- Se não encontrar dados REAIS, retorne []. +- Não inclua exemplos, placeholders ou valores estimados. + +6) RETORNO: +Apenas JSON conforme o schema. NÃO retorne texto fora do JSON. + +IMPORTANTE: Nesta etapa, NÃO busque preços no Brasil (Mercado Livre/Amazon).`; + + // Initialize Model (Stable SDK) + const model = genAI.getGenerativeModel({ + model: "gemini-1.5-pro", + tools: [{ googleSearch: {} }] + }); + + const geminiResp = await model.generateContent({ + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: "application/json", + responseSchema: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + name: { type: SchemaType.STRING }, + priceUSD: { type: SchemaType.NUMBER }, + priceBRL: { type: SchemaType.NUMBER }, + store: { type: SchemaType.STRING }, + url: { type: SchemaType.STRING }, + }, + required: ["name", "priceUSD", "store", "url"], + }, + }, + }, + }); + + const geminiResult = geminiResp.response; + const raw = geminiResult.text(); + + let products: any[] = []; + try { + products = JSON.parse(raw); + if (!Array.isArray(products)) products = []; + } catch { + products = []; + } + + const sources = geminiResult.candidates?.[0]?.groundingMetadata?.groundingChunks || []; + + // Completa priceBRL + const fx = await getFxRate(); + products = products.map((p) => { + const priceUSD = typeof p?.priceUSD === "number" ? p.priceUSD : null; + const priceBRL = + typeof p?.priceBRL === "number" + ? p.priceBRL + : priceUSD !== null + ? Number((priceUSD * fx).toFixed(2)) + : null; + + return { ...p, priceBRL }; + }); + + // Ordena + products.sort((a, b) => (a.priceUSD ?? 0) - (b.priceUSD ?? 0)); + + // CACHE SAVE + const fetched_at = new Date().toISOString(); + const expires_at = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + + const { error: upsertErr } = await supabase.from("search_cache").upsert({ + query_key, + query_raw: query, + query_norm, + kind, + results: products, + sources, + fetched_at, + expires_at, + }); + + if (upsertErr) console.error("upsertErr:", upsertErr); + + return new Response( + JSON.stringify({ + cache: "MISS", + fetched_at, + expires_at, + fx, + products, + sources, + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Function Error:", error); + return new Response(JSON.stringify({ error: String(error?.message ?? error) }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +});