- {/* 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 */}
- 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" },
+ });
+ }
+});