feat: implement market page with QueryProvider and dynamic listings
- wrap root layout with QueryProvider for React Query - replace market UnderConstruction placeholder with MarketClient and real data fetching - implement search, pagination, and sort via search params using getActiveListings - add necessary client components and queries - update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
1 parent
55eebde0ba
commit
ab37b477df
106 files changed
+15261
-301
No files matched your search
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Background Jobs para Telemetria
|
||||
* Agregação de métricas e expiração de anúncios
|
||||
*/
|
||||
|
||||
import { aggregateHourlyMetrics } from "@/lib/db/queries/telemetry";
|
||||
import { query } from "@/lib/db/index";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
/**
|
||||
* Agrega eventos de telemetria em métricas
|
||||
* Executar a cada 1 hora via cron ou scheduler
|
||||
*/
|
||||
export async function aggregateTelemetry(): Promise<void> {
|
||||
try {
|
||||
secureLogger.info("🔄 Iniciando agregação de telemetria...");
|
||||
|
||||
await aggregateHourlyMetrics();
|
||||
|
||||
secureLogger.info("✅ Agregação de telemetria concluída");
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao agregar telemetria", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expira anúncios antigos (30 dias)
|
||||
* Executar diariamente
|
||||
*/
|
||||
export async function expireOldListings(): Promise<void> {
|
||||
try {
|
||||
secureLogger.info("🔄 Iniciando expiração de anúncios antigos...");
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE listings
|
||||
SET listing_status = 'expired',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE listing_status = 'active'
|
||||
AND (
|
||||
expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
|
||||
OR
|
||||
(expires_at IS NULL AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 days')
|
||||
)
|
||||
RETURNING listing_id
|
||||
`,
|
||||
[]
|
||||
);
|
||||
|
||||
const expiredCount = result.rowCount || 0;
|
||||
secureLogger.info(`✅ ${expiredCount} anúncios expirados`);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao expirar anúncios", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza histórico de preços quando mudar
|
||||
* (Já implementado via trigger, mas pode ser chamado manualmente)
|
||||
*/
|
||||
export async function updatePriceHistory(): Promise<void> {
|
||||
try {
|
||||
secureLogger.info("🔄 Verificando histórico de preços...");
|
||||
|
||||
// O trigger já faz isso automaticamente, mas podemos adicionar lógica adicional aqui
|
||||
// Por exemplo, verificar se há inconsistências ou limpar históricos muito antigos
|
||||
|
||||
secureLogger.info("✅ Histórico de preços verificado");
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao atualizar histórico de preços", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa todos os background jobs
|
||||
*/
|
||||
export async function runBackgroundJobs(): Promise<void> {
|
||||
try {
|
||||
secureLogger.info("🚀 Iniciando background jobs...");
|
||||
|
||||
await Promise.all([
|
||||
aggregateTelemetry(),
|
||||
expireOldListings(),
|
||||
updatePriceHistory(),
|
||||
]);
|
||||
|
||||
secureLogger.info("✅ Todos os background jobs concluídos");
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao executar background jobs", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+28
-5
@@ -74,11 +74,34 @@ export async function query<T = any>(
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao executar query:", {
|
||||
query: text.substring(0, 100),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorCode = error?.code;
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Ignorar silenciosamente erros relacionados à tabela "items" não existir
|
||||
// Isso é esperado durante a transição e já está sendo tratado nas funções
|
||||
const isItemsTableMissing =
|
||||
errorCode === "42P01" &&
|
||||
(errorMessage.includes('relation "items" does not exist') ||
|
||||
text.includes('FROM items') ||
|
||||
text.includes('FROM "items"'));
|
||||
|
||||
// Não logar erros esperados sobre tabela items não existir
|
||||
if (!isItemsTableMissing) {
|
||||
console.error("❌ Erro ao executar query:", {
|
||||
query: text.substring(0, 200),
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
// Se o erro for sobre coluna não existir, sugerir executar migração
|
||||
if (errorMessage.includes("column") && errorMessage.includes("does not exist")) {
|
||||
console.error("\n💡 DICA: Parece que a migração do banco de dados não foi executada.");
|
||||
console.error(" Execute: pnpm run db:migrate\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
-- Migration 012: Telemetria e Expansão de Anúncios
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- ============================================
|
||||
-- 1. Tabela de Eventos de Telemetria
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS telemetry_events (
|
||||
event_id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
ad_id INTEGER REFERENCES listings(listing_id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
session_id VARCHAR(255),
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_ad_id ON telemetry_events(ad_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_user_id ON telemetry_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry_events(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_event_type ON telemetry_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_session_id ON telemetry_events(session_id);
|
||||
|
||||
-- Índice composto para queries de analytics
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_ad_type_time ON telemetry_events(ad_id, event_type, timestamp DESC);
|
||||
|
||||
-- ============================================
|
||||
-- 2. Expansão da Tabela listings
|
||||
-- ============================================
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS favorites_count INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS inquiries_count INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS price_history JSONB DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP;
|
||||
|
||||
-- Índices para novos campos
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_is_featured ON listings(is_featured) WHERE is_featured = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_expires_at ON listings(expires_at) WHERE expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_favorites_count ON listings(favorites_count DESC);
|
||||
|
||||
-- ============================================
|
||||
-- 3. Tabela de Analytics Agregados
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS ad_analytics (
|
||||
ad_id INTEGER PRIMARY KEY REFERENCES listings(listing_id) ON DELETE CASCADE,
|
||||
impressions INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0,
|
||||
ctr DECIMAL(5, 2) DEFAULT 0.00, -- Click-through rate em porcentagem
|
||||
avg_view_duration DECIMAL(10, 2) DEFAULT 0.00, -- em segundos
|
||||
bounce_rate DECIMAL(5, 2) DEFAULT 0.00, -- em porcentagem
|
||||
inquiries INTEGER DEFAULT 0,
|
||||
favorites INTEGER DEFAULT 0,
|
||||
conversion_rate DECIMAL(5, 2) DEFAULT 0.00, -- em porcentagem
|
||||
time_to_sell INTEGER DEFAULT NULL, -- em horas
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Trigger para atualizar last_updated automaticamente
|
||||
CREATE OR REPLACE FUNCTION update_ad_analytics_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.last_updated = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_ad_analytics_updated_at
|
||||
BEFORE UPDATE ON ad_analytics
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ad_analytics_updated_at();
|
||||
|
||||
-- ============================================
|
||||
-- 4. Tabela de Inventário do Usuário
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS user_inventory (
|
||||
inventory_id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL,
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
quantity INTEGER DEFAULT 1 CHECK (quantity > 0),
|
||||
acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_id)
|
||||
);
|
||||
|
||||
-- Índices
|
||||
CREATE INDEX IF NOT EXISTS idx_user_inventory_user_id ON user_inventory(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_inventory_item_id ON user_inventory(item_id);
|
||||
|
||||
-- ============================================
|
||||
-- 5. Tabela de Favoritos
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
ad_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, ad_id)
|
||||
);
|
||||
|
||||
-- Índices
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_ad_id ON user_favorites(ad_id);
|
||||
|
||||
-- ============================================
|
||||
-- 6. Trigger para Atualizar favorites_count
|
||||
-- ============================================
|
||||
CREATE OR REPLACE FUNCTION update_listing_favorites_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE listings
|
||||
SET favorites_count = favorites_count + 1
|
||||
WHERE listing_id = NEW.ad_id;
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE listings
|
||||
SET favorites_count = GREATEST(0, favorites_count - 1)
|
||||
WHERE listing_id = OLD.ad_id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_listing_favorites_count
|
||||
AFTER INSERT OR DELETE ON user_favorites
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_listing_favorites_count();
|
||||
|
||||
-- ============================================
|
||||
-- 7. Função para Inicializar Analytics ao Criar Anúncio
|
||||
-- ============================================
|
||||
CREATE OR REPLACE FUNCTION initialize_ad_analytics()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO ad_analytics (ad_id)
|
||||
VALUES (NEW.listing_id)
|
||||
ON CONFLICT (ad_id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_initialize_ad_analytics
|
||||
AFTER INSERT ON listings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION initialize_ad_analytics();
|
||||
|
||||
-- ============================================
|
||||
-- 8. Função para Atualizar Price History
|
||||
-- ============================================
|
||||
CREATE OR REPLACE FUNCTION update_price_history()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_price_entry JSONB;
|
||||
current_history JSONB;
|
||||
BEGIN
|
||||
-- Verifica se algum preço mudou
|
||||
IF (OLD.price_pix IS DISTINCT FROM NEW.price_pix) OR
|
||||
(OLD.price_tibia_coins IS DISTINCT FROM NEW.price_tibia_coins) OR
|
||||
(OLD.price_gold_coins IS DISTINCT FROM NEW.price_gold_coins) THEN
|
||||
|
||||
-- Cria entrada de histórico
|
||||
new_price_entry := jsonb_build_object(
|
||||
'timestamp', CURRENT_TIMESTAMP,
|
||||
'price_pix', NEW.price_pix,
|
||||
'price_tibia_coins', NEW.price_tibia_coins,
|
||||
'price_gold_coins', NEW.price_gold_coins
|
||||
);
|
||||
|
||||
-- Obtém histórico atual
|
||||
current_history := COALESCE(NEW.price_history, '[]'::jsonb);
|
||||
|
||||
-- Adiciona nova entrada no início do array
|
||||
NEW.price_history := jsonb_insert(
|
||||
current_history,
|
||||
'{0}',
|
||||
new_price_entry
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_price_history
|
||||
BEFORE UPDATE ON listings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_price_history();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Migration 013: Suporte a troca por item em anúncios
|
||||
-- Criado em: 2025-01-29
|
||||
|
||||
-- Adicionar colunas para troca por item
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS trade_item_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS trade_item_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS trade_item_quantity INTEGER DEFAULT 1 CHECK (trade_item_quantity > 0);
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN listings.trade_item_id IS 'ID do item aceito em troca (referência ao banco de itens do TibiaWiki)';
|
||||
COMMENT ON COLUMN listings.trade_item_name IS 'Nome do item aceito em troca';
|
||||
COMMENT ON COLUMN listings.trade_item_quantity IS 'Quantidade do item aceito em troca';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Migration 014: Adicionar campos de incremento mínimo e preços iniciais para leilões
|
||||
-- Criado em: 2025-01-29
|
||||
|
||||
-- Adicionar colunas para incremento mínimo de lance
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS min_bid_increment DECIMAL(10, 2);
|
||||
|
||||
-- Adicionar colunas para armazenar preços iniciais (para leilões)
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS initial_price_pix DECIMAL(10, 2),
|
||||
ADD COLUMN IF NOT EXISTS initial_price_tibia_coins INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS initial_price_gold_coins BIGINT;
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN listings.min_bid_increment IS 'Incremento mínimo para novos lances (padrão: 5% do preço inicial ou valor fixo)';
|
||||
COMMENT ON COLUMN listings.initial_price_pix IS 'Preço inicial em PIX (para leilões)';
|
||||
COMMENT ON COLUMN listings.initial_price_tibia_coins IS 'Preço inicial em Tibia Coins (para leilões)';
|
||||
COMMENT ON COLUMN listings.initial_price_gold_coins IS 'Preço inicial em Gold Coins (para leilões)';
|
||||
|
||||
-- Atualizar preços iniciais para leilões existentes (copiar dos preços atuais)
|
||||
UPDATE listings
|
||||
SET
|
||||
initial_price_pix = price_pix,
|
||||
initial_price_tibia_coins = price_tibia_coins,
|
||||
initial_price_gold_coins = price_gold_coins
|
||||
WHERE is_auction = TRUE
|
||||
AND (initial_price_pix IS NULL OR initial_price_tibia_coins IS NULL OR initial_price_gold_coins IS NULL);
|
||||
|
||||
-- Calcular incremento mínimo para leilões existentes (5% do preço inicial ou mínimo de R$ 10,00)
|
||||
UPDATE listings
|
||||
SET min_bid_increment = GREATEST(
|
||||
COALESCE(initial_price_pix * 0.05, 0),
|
||||
10.00
|
||||
)
|
||||
WHERE is_auction = TRUE
|
||||
AND min_bid_increment IS NULL
|
||||
AND initial_price_pix IS NOT NULL;
|
||||
|
||||
-- Para leilões com preço em TC ou GP, usar incremento fixo de 5% ou mínimo
|
||||
UPDATE listings
|
||||
SET min_bid_increment = COALESCE(
|
||||
min_bid_increment,
|
||||
GREATEST(
|
||||
CASE
|
||||
WHEN initial_price_tibia_coins IS NOT NULL THEN initial_price_tibia_coins * 0.05
|
||||
WHEN initial_price_gold_coins IS NOT NULL THEN initial_price_gold_coins * 0.05
|
||||
ELSE 10.00
|
||||
END,
|
||||
10.00
|
||||
)
|
||||
)
|
||||
WHERE is_auction = TRUE
|
||||
AND min_bid_increment IS NULL;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Migration 015: Sistema de Intenções de Compra
|
||||
-- Criado em: 2025-01-29
|
||||
|
||||
CREATE TABLE IF NOT EXISTS purchase_intentions (
|
||||
intention_id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
|
||||
buyer_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
payment_method VARCHAR(20) NOT NULL CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins', 'trade')),
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired', 'completed')),
|
||||
buyer_message TEXT,
|
||||
seller_response TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
rejected_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_listing_id ON purchase_intentions(listing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_buyer_id ON purchase_intentions(buyer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_status ON purchase_intentions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_created_at ON purchase_intentions(created_at DESC);
|
||||
|
||||
-- Constraint: apenas uma intenção pendente por listing+buyer
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_intentions_unique_pending
|
||||
ON purchase_intentions(listing_id, buyer_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Trigger para atualizar updated_at
|
||||
CREATE OR REPLACE FUNCTION update_purchase_intentions_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_purchase_intentions_updated_at
|
||||
BEFORE UPDATE ON purchase_intentions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_purchase_intentions_updated_at();
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON TABLE purchase_intentions IS 'Intenções de compra de anúncios - permite múltiplos compradores interessados';
|
||||
COMMENT ON COLUMN purchase_intentions.status IS 'pending: aguardando resposta do vendedor, accepted: aceita, rejected: rejeitada, expired: expirada, completed: concluída';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Migration 016: Tabela Unificada de Itens
|
||||
-- Criado em: 2025-01-29
|
||||
-- Objetivo: Unificar todos os itens de todas as categorias do SQLite em uma única tabela
|
||||
-- com IDs únicos e consistentes, permitindo JOINs confiáveis
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
item_id SERIAL PRIMARY KEY,
|
||||
-- ID original da tabela de origem (para referência)
|
||||
source_table_id INTEGER NOT NULL,
|
||||
-- Nome da tabela de origem (espadas, armaduras, etc)
|
||||
source_table_name VARCHAR(50) NOT NULL,
|
||||
-- Nome do item (normalizado)
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
-- Categoria (normalizada para inglês)
|
||||
category VARCHAR(50) NOT NULL,
|
||||
-- Caminho da imagem local (se houver)
|
||||
image_local_path TEXT,
|
||||
-- URL da imagem externa (se houver)
|
||||
image_url TEXT,
|
||||
-- Raridade
|
||||
rarity VARCHAR(20),
|
||||
-- NÃvel (se aplicável)
|
||||
level INTEGER,
|
||||
-- Peso (se aplicável)
|
||||
weight DECIMAL(10, 2),
|
||||
-- Slots (se aplicável)
|
||||
slots INTEGER,
|
||||
-- Skill para usar (se aplicável)
|
||||
skill VARCHAR(50),
|
||||
-- Dados adicionais em JSON (para campos extras que variam por categoria)
|
||||
metadata JSONB DEFAULT '{}',
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Ãndices para performance
|
||||
CONSTRAINT unique_source_item UNIQUE (source_table_name, source_table_id)
|
||||
);
|
||||
|
||||
-- Ãndices para busca rápida
|
||||
CREATE INDEX IF NOT EXISTS idx_items_name ON items(item_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_rarity ON items(rarity);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_name_lower ON items(LOWER(item_name));
|
||||
CREATE INDEX IF NOT EXISTS idx_items_category_name ON items(category, item_name);
|
||||
|
||||
-- Ãndice GIN para busca em JSONB
|
||||
CREATE INDEX IF NOT EXISTS idx_items_metadata ON items USING GIN (metadata);
|
||||
|
||||
-- Trigger para atualizar updated_at
|
||||
CREATE OR REPLACE FUNCTION update_items_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_items_updated_at
|
||||
BEFORE UPDATE ON items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_items_updated_at();
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON TABLE items IS 'Tabela unificada de itens - todos os itens de todas as categorias com IDs únicos';
|
||||
COMMENT ON COLUMN items.source_table_id IS 'ID original do item na tabela de origem (SQLite)';
|
||||
COMMENT ON COLUMN items.source_table_name IS 'Nome da tabela de origem (espadas, armaduras, etc)';
|
||||
COMMENT ON COLUMN items.category IS 'Categoria normalizada (weapon, armor, helmet, etc)';
|
||||
COMMENT ON COLUMN items.metadata IS 'Campos extras que variam por categoria (proteção, bonus, etc)';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration 017: Adicionar Foreign Key e Atualizar Listings
|
||||
-- Criado em: 2025-01-29
|
||||
-- Objetivo: Adicionar foreign key de listings para items e garantir integridade
|
||||
|
||||
-- Primeiro, adicionar coluna item_id_unified se não existir (para migração gradual)
|
||||
-- Depois, podemos fazer migration dos dados existentes
|
||||
|
||||
-- Por enquanto, vamos manter item_id como está e adicionar uma coluna de referência
|
||||
-- que será populada gradualmente
|
||||
|
||||
-- Adicionar foreign key para items (opcional, permite NULL para itens antigos durante migração)
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS item_id_unified INTEGER REFERENCES items(item_id) ON DELETE SET NULL;
|
||||
|
||||
-- Ãndice para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_item_id_unified ON listings(item_id_unified);
|
||||
|
||||
-- Comentário
|
||||
COMMENT ON COLUMN listings.item_id_unified IS 'Referência ao item na tabela unificada items (substitui item_id no futuro)';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Migration 018: Atualizar Listings para usar item_id_unified como padrão
|
||||
-- Criado em: 2025-01-29
|
||||
-- Objetivo: Migrar dados existentes de item_id para item_id_unified baseado em correspondência
|
||||
|
||||
-- Primeiro, vamos popular item_id_unified baseado em correspondência de nome
|
||||
-- Isso é uma migração gradual - mantemos item_id para compatibilidade
|
||||
|
||||
UPDATE listings l
|
||||
SET item_id_unified = (
|
||||
SELECT i.item_id
|
||||
FROM items i
|
||||
WHERE LOWER(i.item_name) = LOWER(l.item_name)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE l.item_id_unified IS NULL;
|
||||
|
||||
-- Comentário
|
||||
COMMENT ON COLUMN listings.item_id IS 'ID legado do SQLite - será substituÃdo por item_id_unified';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Script de execução de migrations
|
||||
/**
|
||||
* Script de execução de migrations
|
||||
* Executa todas as migrations SQL em ordem
|
||||
*/
|
||||
|
||||
// Carregar variáveis de ambiente
|
||||
// Carregar variáveis de ambiente
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
@@ -18,8 +18,7 @@ import { getPool, query, healthCheck } from "../index";
|
||||
const MIGRATIONS_DIR = join(__dirname, ".");
|
||||
|
||||
// Lista de migrations em ordem
|
||||
const MIGRATIONS = [
|
||||
"001_users.sql",
|
||||
const MIGRATIONS = [ "001_users.sql",
|
||||
"002_servers.sql",
|
||||
"003_fix_servers_location.sql",
|
||||
"004_listings.sql",
|
||||
@@ -30,21 +29,27 @@ const MIGRATIONS = [
|
||||
"009_item_price_history.sql",
|
||||
"010_notifications.sql",
|
||||
"011_donations.sql",
|
||||
];
|
||||
"012_telemetry_and_ads_enhancement.sql",
|
||||
"013_add_trade_item_support.sql",
|
||||
"014_add_bid_increment.sql",
|
||||
"015_add_purchase_intentions.sql",
|
||||
"016_create_unified_items_table.sql",
|
||||
"017_add_items_foreign_key.sql",
|
||||
"018_migrate_listings_to_unified_items.sql",];
|
||||
|
||||
async function runMigrations() {
|
||||
console.log("🚀 Iniciando execução de migrations...\n");
|
||||
console.log("🚀 Iniciando execução de migrations...\n");
|
||||
|
||||
// Verificar conexão com banco
|
||||
console.log("🔍 Verificando conexão com PostgreSQL...");
|
||||
// Verificar conexão com banco
|
||||
console.log("🔠Verificando conexão com PostgreSQL...");
|
||||
const isHealthy = await healthCheck();
|
||||
|
||||
if (!isHealthy) {
|
||||
console.error("❌ Falha na conexão com PostgreSQL. Verifique a variável PG_DB.");
|
||||
console.error("⌠Falha na conexão com PostgreSQL. Verifique a variável PG_DB.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✅ Conexão estabelecida com sucesso\n");
|
||||
console.log("✅ Conexão estabelecida com sucesso\n");
|
||||
|
||||
// Verificar tabela de controle de migrations (opcional)
|
||||
try {
|
||||
@@ -55,7 +60,7 @@ async function runMigrations() {
|
||||
)
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao criar tabela de controle:", error);
|
||||
console.error("⌠Erro ao criar tabela de controle:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -65,18 +70,18 @@ async function runMigrations() {
|
||||
const version = migrationFile.replace(".sql", "");
|
||||
|
||||
try {
|
||||
// Verificar se já foi executada
|
||||
// Verificar se já foi executada
|
||||
const checkResult = await query<{ version: string }>(
|
||||
"SELECT version FROM schema_migrations WHERE version = $1",
|
||||
[version]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length > 0) {
|
||||
console.log(`⏭️ Migration ${version} já executada, pulando...`);
|
||||
console.log(`âï¸ Migration ${version} já executada, pulando...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📝 Executando migration: ${migrationFile}...`);
|
||||
console.log(`📠Executando migration: ${migrationFile}...`);
|
||||
|
||||
// Ler arquivo SQL
|
||||
const sql = readFileSync(migrationPath, "utf-8");
|
||||
@@ -90,14 +95,14 @@ async function runMigrations() {
|
||||
[version]
|
||||
);
|
||||
|
||||
console.log(`✅ Migration ${version} executada com sucesso\n`);
|
||||
console.log(`✅ Migration ${version} executada com sucesso\n`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Erro ao executar migration ${version}:`, error);
|
||||
console.error(`⌠Erro ao executar migration ${version}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎉 Todas as migrations foram executadas com sucesso!");
|
||||
console.log("🎉 Todas as migrations foram executadas com sucesso!");
|
||||
|
||||
// Fechar pool
|
||||
const pool = getPool();
|
||||
@@ -107,7 +112,8 @@ async function runMigrations() {
|
||||
|
||||
// Executar
|
||||
runMigrations().catch((error) => {
|
||||
console.error("❌ Erro fatal:", error);
|
||||
console.error("⌠Erro fatal:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,27 @@ export async function getUserCharacters(userId: number): Promise<Character[]> {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca personagem principal de um usuário em um servidor específico
|
||||
* Retorna o personagem principal (is_main = true) ou o primeiro personagem do servidor
|
||||
*/
|
||||
export async function getMainCharacterByUserId(
|
||||
userId: number,
|
||||
serverName: string
|
||||
): Promise<Character | null> {
|
||||
const result = await query<Character>(
|
||||
`
|
||||
SELECT * FROM user_characters
|
||||
WHERE user_id = $1 AND server_name = $2
|
||||
ORDER BY is_main DESC, created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[userId, serverName]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca personagem por ID
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Queries de itens unificados
|
||||
* Busca itens da tabela unificada items (PostgreSQL)
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface UnifiedItem {
|
||||
item_id: number;
|
||||
source_table_id: number;
|
||||
source_table_name: string;
|
||||
item_name: string;
|
||||
category: string;
|
||||
image_local_path: string | null;
|
||||
image_url: string | null;
|
||||
rarity: string | null;
|
||||
level: number | null;
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
metadata: any; // JSONB
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca item por ID unificado
|
||||
*/
|
||||
export async function getItemByIdUnified(itemId: number): Promise<UnifiedItem | null> {
|
||||
try {
|
||||
const result = await query<UnifiedItem>(
|
||||
`
|
||||
SELECT
|
||||
item_id,
|
||||
source_table_id,
|
||||
source_table_name,
|
||||
item_name,
|
||||
category,
|
||||
image_local_path,
|
||||
image_url,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM items
|
||||
WHERE item_id = $1
|
||||
`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
// Se a tabela não existe (código 42P01), retornar null silenciosamente
|
||||
// Isso permite fallback para SQLite sem poluir logs
|
||||
if (error?.code === "42P01") {
|
||||
return null;
|
||||
}
|
||||
// Para outros erros, re-lançar
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca item por nome (case-insensitive)
|
||||
*/
|
||||
export async function getItemByNameUnified(itemName: string): Promise<UnifiedItem | null> {
|
||||
try {
|
||||
const result = await query<UnifiedItem>(
|
||||
`
|
||||
SELECT
|
||||
item_id,
|
||||
source_table_id,
|
||||
source_table_name,
|
||||
item_name,
|
||||
category,
|
||||
image_local_path,
|
||||
image_url,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM items
|
||||
WHERE LOWER(item_name) = LOWER($1)
|
||||
LIMIT 1
|
||||
`,
|
||||
[itemName]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
// Se a tabela não existe (código 42P01), retornar null silenciosamente
|
||||
if (error?.code === "42P01") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se um item existe na tabela unificada
|
||||
*/
|
||||
export async function itemExists(itemId: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await query<{ exists: boolean }>(
|
||||
`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM items WHERE item_id = $1
|
||||
) as exists
|
||||
`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
return result.rows[0]?.exists || false;
|
||||
} catch (error: any) {
|
||||
// Se a tabela não existe (código 42P01), retornar false silenciosamente
|
||||
if (error?.code === "42P01") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+656
-62
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
import { itemExists } from "./items";
|
||||
|
||||
export interface Listing {
|
||||
listing_id: number;
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_id: number; // ID legado (SQLite) - mantido para compatibilidade
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
@@ -25,11 +26,34 @@ export interface Listing {
|
||||
updated_at: Date;
|
||||
sold_at: Date | null;
|
||||
views_count: number;
|
||||
// Campos adicionados na migração 012
|
||||
is_featured?: boolean;
|
||||
favorites_count?: number;
|
||||
inquiries_count?: number;
|
||||
price_history?: any; // JSONB
|
||||
expires_at?: Date | null;
|
||||
// Campos adicionados na migração 013 (troca por item)
|
||||
trade_item_id?: number | null;
|
||||
trade_item_name?: string | null;
|
||||
trade_item_quantity?: number | null;
|
||||
// Campos adicionados na migração 014 (leilões)
|
||||
min_bid_increment?: number | null;
|
||||
initial_price_pix?: number | null;
|
||||
initial_price_tibia_coins?: number | null;
|
||||
initial_price_gold_coins?: number | null;
|
||||
// Campos do JOIN com items (unificado)
|
||||
item_id_unified?: number | null; // ID unificado da tabela items
|
||||
image_local_path?: string | null;
|
||||
image_url?: string | null;
|
||||
item_category?: string | null;
|
||||
// Campo calculado (não vem do banco diretamente)
|
||||
seller_character_name?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateListingData {
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_id?: number; // ID legado (SQLite) - opcional, mantido para compatibilidade
|
||||
item_id_unified?: number; // ID unificado da tabela items - preferido
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
@@ -39,6 +63,10 @@ export interface CreateListingData {
|
||||
is_auction?: boolean;
|
||||
auction_ends_at?: Date;
|
||||
description?: string;
|
||||
trade_item_id?: number | null;
|
||||
trade_item_name?: string | null;
|
||||
trade_item_quantity?: number | null;
|
||||
min_bid_increment?: number;
|
||||
}
|
||||
|
||||
export interface UpdateListingData {
|
||||
@@ -71,30 +99,168 @@ export interface ListingFilters {
|
||||
export async function createListing(
|
||||
data: CreateListingData
|
||||
): Promise<Listing> {
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
INSERT INTO listings (
|
||||
seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, auction_ends_at, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
data.seller_id,
|
||||
data.item_id,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.price_pix || null,
|
||||
data.price_tibia_coins || null,
|
||||
data.price_gold_coins || null,
|
||||
data.is_auction || false,
|
||||
data.auction_ends_at || null,
|
||||
data.description || null,
|
||||
]
|
||||
);
|
||||
// Validar item_id_unified se fornecido
|
||||
if (data.item_id_unified) {
|
||||
const exists = await itemExists(data.item_id_unified);
|
||||
if (!exists) {
|
||||
throw new Error(`Item com ID unificado ${data.item_id_unified} não encontrado na tabela items`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular incremento mínimo se for leilão e não foi fornecido
|
||||
let minBidIncrement = data.min_bid_increment;
|
||||
if (data.is_auction && !minBidIncrement) {
|
||||
// 5% do preço inicial ou mínimo de R$ 10,00
|
||||
const initialPrice = data.price_pix || 0;
|
||||
minBidIncrement = Math.max(initialPrice * 0.05, 10.0);
|
||||
}
|
||||
|
||||
// Usar item_id_unified se fornecido, senão usar item_id legado
|
||||
const itemIdUnified = data.item_id_unified || null;
|
||||
const itemIdLegacy = data.item_id || null;
|
||||
|
||||
let result;
|
||||
try {
|
||||
// Tentar INSERT com todas as colunas (se migração 014 foi executada)
|
||||
result = await query<Listing>(
|
||||
`
|
||||
INSERT INTO listings (
|
||||
seller_id, item_id, item_id_unified, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, auction_ends_at, description,
|
||||
trade_item_id, trade_item_name, trade_item_quantity,
|
||||
initial_price_pix, initial_price_tibia_coins, initial_price_gold_coins,
|
||||
min_bid_increment
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
RETURNING
|
||||
listing_id, seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
|
||||
description, listing_status, created_at, updated_at, sold_at, views_count,
|
||||
COALESCE(is_featured, false) as is_featured,
|
||||
COALESCE(favorites_count, 0) as favorites_count,
|
||||
COALESCE(inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(price_history, '[]'::jsonb) as price_history,
|
||||
expires_at,
|
||||
trade_item_id, trade_item_name, trade_item_quantity,
|
||||
min_bid_increment, initial_price_pix, initial_price_tibia_coins, initial_price_gold_coins
|
||||
`,
|
||||
[
|
||||
data.seller_id,
|
||||
itemIdLegacy,
|
||||
itemIdUnified,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.price_pix || null,
|
||||
data.price_tibia_coins || null,
|
||||
data.price_gold_coins || null,
|
||||
data.is_auction || false,
|
||||
data.auction_ends_at || null,
|
||||
data.description || null,
|
||||
data.trade_item_id || null,
|
||||
data.trade_item_name || null,
|
||||
data.trade_item_quantity || null,
|
||||
data.is_auction ? (data.price_pix || null) : null, // initial_price_pix
|
||||
data.is_auction ? (data.price_tibia_coins || null) : null, // initial_price_tibia_coins
|
||||
data.is_auction ? (data.price_gold_coins || null) : null, // initial_price_gold_coins
|
||||
minBidIncrement || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
// Se as colunas da migração 014 ou item_id_unified não existirem, usar INSERT sem elas
|
||||
if (error instanceof Error && error.message.includes("does not exist")) {
|
||||
try {
|
||||
// Tentar com item_id_unified mas sem colunas de migração 014
|
||||
result = await query<Listing>(
|
||||
`
|
||||
INSERT INTO listings (
|
||||
seller_id, item_id, item_id_unified, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, auction_ends_at, description,
|
||||
trade_item_id, trade_item_name, trade_item_quantity
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING
|
||||
listing_id, seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
|
||||
description, listing_status, created_at, updated_at, sold_at, views_count,
|
||||
COALESCE(is_featured, false) as is_featured,
|
||||
COALESCE(favorites_count, 0) as favorites_count,
|
||||
COALESCE(inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(price_history, '[]'::jsonb) as price_history,
|
||||
expires_at,
|
||||
trade_item_id, trade_item_name, trade_item_quantity
|
||||
`,
|
||||
[
|
||||
data.seller_id,
|
||||
itemIdLegacy,
|
||||
itemIdUnified,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.price_pix || null,
|
||||
data.price_tibia_coins || null,
|
||||
data.price_gold_coins || null,
|
||||
data.is_auction || false,
|
||||
data.auction_ends_at || null,
|
||||
data.description || null,
|
||||
data.trade_item_id || null,
|
||||
data.trade_item_name || null,
|
||||
data.trade_item_quantity || null,
|
||||
]
|
||||
);
|
||||
} catch (error2) {
|
||||
// Se item_id_unified também não existir, usar apenas item_id legado
|
||||
if (error2 instanceof Error && error2.message.includes("does not exist")) {
|
||||
result = await query<Listing>(
|
||||
`
|
||||
INSERT INTO listings (
|
||||
seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, auction_ends_at, description,
|
||||
trade_item_id, trade_item_name, trade_item_quantity
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING
|
||||
listing_id, seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
|
||||
description, listing_status, created_at, updated_at, sold_at, views_count,
|
||||
COALESCE(is_featured, false) as is_featured,
|
||||
COALESCE(favorites_count, 0) as favorites_count,
|
||||
COALESCE(inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(price_history, '[]'::jsonb) as price_history,
|
||||
expires_at,
|
||||
trade_item_id, trade_item_name, trade_item_quantity
|
||||
`,
|
||||
[
|
||||
data.seller_id,
|
||||
itemIdLegacy,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.price_pix || null,
|
||||
data.price_tibia_coins || null,
|
||||
data.price_gold_coins || null,
|
||||
data.is_auction || false,
|
||||
data.auction_ends_at || null,
|
||||
data.description || null,
|
||||
data.trade_item_id || null,
|
||||
data.trade_item_name || null,
|
||||
data.trade_item_quantity || null,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
throw error2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -103,14 +269,85 @@ export async function createListing(
|
||||
* Busca anúncio por ID
|
||||
*/
|
||||
export async function getListingById(listingId: number): Promise<Listing | null> {
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
SELECT * FROM listings WHERE listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
// Tentar query com todas as colunas (se migração 014 foi executada)
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
} catch (error) {
|
||||
// Se as colunas da migração 014 não existirem, usar query sem elas
|
||||
if (error instanceof Error && error.message.includes("does not exist")) {
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
NULL::numeric as min_bid_increment,
|
||||
NULL::numeric as initial_price_pix,
|
||||
NULL::integer as initial_price_tibia_coins,
|
||||
NULL::bigint as initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result.rows[0] || null;
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
seller_character_name: row.seller_character_name || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,69 +374,153 @@ export async function getListings(
|
||||
let paramIndex = 1;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`listing_status = $${paramIndex}`);
|
||||
conditions.push(`l.listing_status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (server_name) {
|
||||
conditions.push(`server_name = $${paramIndex}`);
|
||||
conditions.push(`l.server_name = $${paramIndex}`);
|
||||
params.push(server_name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (item_id) {
|
||||
conditions.push(`item_id = $${paramIndex}`);
|
||||
conditions.push(`l.item_id = $${paramIndex}`);
|
||||
params.push(item_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (seller_id) {
|
||||
conditions.push(`seller_id = $${paramIndex}`);
|
||||
conditions.push(`l.seller_id = $${paramIndex}`);
|
||||
params.push(seller_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (is_auction !== undefined) {
|
||||
conditions.push(`is_auction = $${paramIndex}`);
|
||||
conditions.push(`l.is_auction = $${paramIndex}`);
|
||||
params.push(is_auction);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(item_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||
conditions.push(`(l.item_name ILIKE $${paramIndex} OR l.description ILIKE $${paramIndex})`);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "WHERE 1=1";
|
||||
|
||||
// Contar total
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM listings ${whereClause}`,
|
||||
`SELECT COUNT(*) as count FROM listings l ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Validar sortBy
|
||||
// Validar sortBy - garantir que seja uma coluna válida
|
||||
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
|
||||
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
|
||||
const sortDirection = sortOrder === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
// Mapear colunas para usar alias 'l.' quando necessário
|
||||
const sortColumnWithAlias = `l.${sortColumn}`;
|
||||
|
||||
// Buscar listings
|
||||
const offset = (page - 1) * pageSize;
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
SELECT * FROM listings
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumn} ${sortDirection}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
// Incrementar paramIndex para os parâmetros de LIMIT e OFFSET
|
||||
const limitParamIndex = paramIndex;
|
||||
const offsetParamIndex = paramIndex + 1;
|
||||
const finalParams = [...params, pageSize, offset];
|
||||
|
||||
// Tentar query com todas as colunas (se migração 014 foi executada)
|
||||
// Se falhar, usar query sem as novas colunas
|
||||
let result;
|
||||
try {
|
||||
const querySql = `
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumnWithAlias} ${sortDirection}
|
||||
LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}
|
||||
`;
|
||||
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
querySql,
|
||||
finalParams
|
||||
);
|
||||
} catch (error) {
|
||||
// Se as colunas da migração 014 não existirem, usar query sem elas
|
||||
if (error instanceof Error && error.message.includes("does not exist")) {
|
||||
const fallbackQuerySql = `
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
NULL::numeric as min_bid_increment,
|
||||
NULL::numeric as initial_price_pix,
|
||||
NULL::integer as initial_price_tibia_coins,
|
||||
NULL::bigint as initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumnWithAlias} ${sortDirection}
|
||||
LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}
|
||||
`;
|
||||
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
fallbackQuerySql,
|
||||
finalParams
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapear resultados para garantir que seller_character_name seja null se não existir
|
||||
const listings = result.rows.map(row => ({
|
||||
...row,
|
||||
seller_character_name: row.seller_character_name || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
listings: result.rows,
|
||||
listings,
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -268,17 +589,147 @@ export async function updateListing(
|
||||
}
|
||||
|
||||
params.push(listingId);
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
UPDATE listings
|
||||
SET ${updates.join(", ")}
|
||||
WHERE listing_id = $${paramIndex}
|
||||
RETURNING *
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
let result;
|
||||
try {
|
||||
// Tentar query com todas as colunas (se migração 014 foi executada)
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
UPDATE listings l
|
||||
SET ${updates.join(", ")}
|
||||
WHERE l.listing_id = $${paramIndex}
|
||||
RETURNING
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar dados do JOIN após o UPDATE
|
||||
if (result.rows.length > 0) {
|
||||
const listingId = result.rows[0].listing_id;
|
||||
const joinResult = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
if (joinResult.rows.length > 0) {
|
||||
result.rows[0] = joinResult.rows[0];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Se as colunas da migração 014 não existirem, usar query sem elas
|
||||
if (error instanceof Error && error.message.includes("does not exist")) {
|
||||
result = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
UPDATE listings l
|
||||
SET ${updates.join(", ")}
|
||||
WHERE l.listing_id = $${paramIndex}
|
||||
RETURNING
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
NULL::numeric as min_bid_increment,
|
||||
NULL::numeric as initial_price_pix,
|
||||
NULL::integer as initial_price_tibia_coins,
|
||||
NULL::bigint as initial_price_gold_coins
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar dados do JOIN após o UPDATE
|
||||
if (result.rows.length > 0) {
|
||||
const listingId = result.rows[0].listing_id;
|
||||
const joinResult = await query<Listing & { seller_character_name: string }>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
NULL::numeric as min_bid_increment,
|
||||
NULL::numeric as initial_price_pix,
|
||||
NULL::integer as initial_price_tibia_coins,
|
||||
NULL::bigint as initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category,
|
||||
uc.character_name as seller_character_name
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
|
||||
AND uc.server_name = l.server_name
|
||||
AND uc.is_main = TRUE
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
if (joinResult.rows.length > 0) {
|
||||
result.rows[0] = joinResult.rows[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result.rows[0] || null;
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
seller_character_name: row.seller_character_name || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,3 +787,146 @@ export async function getActiveListings(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coloca um lance em um leilão
|
||||
* Valida se o lance é maior que o lance atual + incremento mínimo
|
||||
*/
|
||||
export async function placeBid(
|
||||
listingId: number,
|
||||
bidderId: number,
|
||||
bidAmount: number,
|
||||
paymentMethod: "pix" | "tc" | "gp"
|
||||
): Promise<{ success: boolean; listing: Listing | null; previousBidderId: number | null; error?: string }> {
|
||||
const { getPool } = await import("../index");
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Buscar listing atual com JOIN
|
||||
const listingResult = await client.query<Listing>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
if (listingResult.rows.length === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return { success: false, listing: null, previousBidderId: null, error: "Anúncio não encontrado" };
|
||||
}
|
||||
|
||||
const listing = listingResult.rows[0];
|
||||
|
||||
// Validações
|
||||
if (listing.listing_status !== "active") {
|
||||
await client.query("ROLLBACK");
|
||||
return { success: false, listing: null, previousBidderId: null, error: "Anúncio não está ativo" };
|
||||
}
|
||||
|
||||
if (!listing.is_auction) {
|
||||
await client.query("ROLLBACK");
|
||||
return { success: false, listing: null, previousBidderId: null, error: "Este anúncio não é um leilão" };
|
||||
}
|
||||
|
||||
if (listing.seller_id === bidderId) {
|
||||
await client.query("ROLLBACK");
|
||||
return { success: false, listing: null, previousBidderId: null, error: "Você não pode dar lance no seu próprio anúncio" };
|
||||
}
|
||||
|
||||
if (listing.auction_ends_at && new Date(listing.auction_ends_at) < new Date()) {
|
||||
await client.query("ROLLBACK");
|
||||
return { success: false, listing: null, previousBidderId: null, error: "Leilão expirou" };
|
||||
}
|
||||
|
||||
// Calcular lance mínimo
|
||||
const currentBid = listing.current_bid_amount || 0;
|
||||
const minIncrement = listing.min_bid_increment || 10; // Padrão de R$ 10,00
|
||||
const minBid = currentBid + minIncrement;
|
||||
|
||||
if (bidAmount < minBid) {
|
||||
await client.query("ROLLBACK");
|
||||
return {
|
||||
success: false,
|
||||
listing: null,
|
||||
previousBidderId: listing.current_bidder_id,
|
||||
error: `Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Guardar ID do lanceador anterior
|
||||
const previousBidderId = listing.current_bidder_id;
|
||||
|
||||
// Atualizar listing com novo lance
|
||||
await client.query(
|
||||
`
|
||||
UPDATE listings
|
||||
SET
|
||||
current_bid_amount = $1,
|
||||
current_bidder_id = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE listing_id = $3
|
||||
`,
|
||||
[bidAmount, bidderId, listingId]
|
||||
);
|
||||
|
||||
// Buscar listing atualizado com JOIN
|
||||
const updateResult = await client.query<Listing>(
|
||||
`
|
||||
SELECT
|
||||
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
|
||||
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
|
||||
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
|
||||
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
|
||||
COALESCE(l.is_featured, false) as is_featured,
|
||||
COALESCE(l.favorites_count, 0) as favorites_count,
|
||||
COALESCE(l.inquiries_count, 0) as inquiries_count,
|
||||
COALESCE(l.price_history, '[]'::jsonb) as price_history,
|
||||
l.expires_at,
|
||||
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
|
||||
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
|
||||
i.item_id as item_id_unified,
|
||||
i.image_local_path,
|
||||
i.image_url,
|
||||
i.category as item_category
|
||||
FROM listings l
|
||||
LEFT JOIN items i ON i.item_id = l.item_id_unified
|
||||
WHERE l.listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
listing: updateResult.rows[0],
|
||||
previousBidderId,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Queries de notificações
|
||||
* CRUD completo para a tabela notifications
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Notification {
|
||||
notification_id: number;
|
||||
user_id: number;
|
||||
notification_type: "bid" | "sale" | "purchase" | "escrow" | "dispute" | "system";
|
||||
title: string;
|
||||
message: string;
|
||||
related_id: number | null;
|
||||
is_read: boolean;
|
||||
send_whatsapp: boolean;
|
||||
whatsapp_sent: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateNotificationData {
|
||||
user_id: number;
|
||||
notification_type: Notification["notification_type"];
|
||||
title: string;
|
||||
message: string;
|
||||
related_id?: number | null;
|
||||
send_whatsapp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma nova notificação
|
||||
*/
|
||||
export async function createNotification(
|
||||
data: CreateNotificationData
|
||||
): Promise<Notification> {
|
||||
const result = await query<Notification>(
|
||||
`
|
||||
INSERT INTO notifications (user_id, notification_type, title, message, related_id, send_whatsapp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
data.user_id,
|
||||
data.notification_type,
|
||||
data.title,
|
||||
data.message,
|
||||
data.related_id || null,
|
||||
data.send_whatsapp || false,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca notificações de um usuário
|
||||
*/
|
||||
export async function getUserNotifications(
|
||||
userId: number,
|
||||
filters: { is_read?: boolean; limit?: number } = {}
|
||||
): Promise<Notification[]> {
|
||||
const { is_read, limit = 50 } = filters;
|
||||
|
||||
const conditions: string[] = [`user_id = $1`];
|
||||
const params: any[] = [userId];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (is_read !== undefined) {
|
||||
conditions.push(`is_read = $${paramIndex}`);
|
||||
params.push(is_read);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const result = await query<Notification>(
|
||||
`
|
||||
SELECT * FROM notifications
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex}
|
||||
`,
|
||||
[...params, limit]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta notificações não lidas de um usuário
|
||||
*/
|
||||
export async function getUnreadCount(userId: number): Promise<number> {
|
||||
const result = await query<{ count: string }>(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM notifications
|
||||
WHERE user_id = $1 AND is_read = FALSE
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca notificação como lida
|
||||
*/
|
||||
export async function markAsRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE notifications
|
||||
SET is_read = TRUE
|
||||
WHERE notification_id = $1 AND user_id = $2
|
||||
`,
|
||||
[notificationId, userId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca todas as notificações de um usuário como lidas
|
||||
*/
|
||||
export async function markAllAsRead(userId: number): Promise<number> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE notifications
|
||||
SET is_read = TRUE
|
||||
WHERE user_id = $1 AND is_read = FALSE
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return result.rowCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove notificação
|
||||
*/
|
||||
export async function deleteNotification(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM notifications
|
||||
WHERE notification_id = $1 AND user_id = $2
|
||||
`,
|
||||
[notificationId, userId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Queries de Telemetria
|
||||
* Sistema completo de tracking e analytics
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export type TelemetryEventType =
|
||||
| "ad_view"
|
||||
| "ad_click"
|
||||
| "ad_exit"
|
||||
| "search"
|
||||
| "filter"
|
||||
| "favorite"
|
||||
| "unfavorite"
|
||||
| "purchase"
|
||||
| "inquiry"
|
||||
| "page_view"
|
||||
| "price_change";
|
||||
|
||||
export interface TelemetryEvent {
|
||||
event_id: number;
|
||||
event_type: TelemetryEventType;
|
||||
ad_id: number | null;
|
||||
user_id: number | null;
|
||||
session_id: string | null;
|
||||
metadata: Record<string, any>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TrackEventData {
|
||||
event_type: TelemetryEventType;
|
||||
ad_id?: number;
|
||||
user_id?: number;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AdAnalytics {
|
||||
ad_id: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
ctr: number;
|
||||
avg_view_duration: number;
|
||||
bounce_rate: number;
|
||||
inquiries: number;
|
||||
favorites: number;
|
||||
conversion_rate: number;
|
||||
time_to_sell: number | null;
|
||||
last_updated: Date;
|
||||
}
|
||||
|
||||
export interface UserBehavior {
|
||||
user_id: number;
|
||||
search_queries: string[];
|
||||
filters_applied: Record<string, any>;
|
||||
items_viewed: Array<{
|
||||
item_id: number;
|
||||
duration: number;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
items_favorited: number[];
|
||||
purchases: Array<{
|
||||
ad_id: number;
|
||||
price: number;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
active_hours: Array<{
|
||||
hour: number;
|
||||
activity_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MarketplaceStats {
|
||||
total_transactions: number;
|
||||
avg_price_by_category: Record<string, number>;
|
||||
conversion_rate: number;
|
||||
most_searched_items: Array<{
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
search_count: number;
|
||||
}>;
|
||||
most_sold_items: Array<{
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
sales_count: number;
|
||||
}>;
|
||||
peak_hours: Array<{
|
||||
hour: number;
|
||||
activity_count: number;
|
||||
}>;
|
||||
avg_time_to_sell: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra um evento de telemetria
|
||||
*/
|
||||
export async function trackEvent(
|
||||
data: TrackEventData
|
||||
): Promise<TelemetryEvent> {
|
||||
const result = await query<TelemetryEvent>(
|
||||
`
|
||||
INSERT INTO telemetry_events (
|
||||
event_type, ad_id, user_id, session_id, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
data.event_type,
|
||||
data.ad_id || null,
|
||||
data.user_id || null,
|
||||
data.session_id || null,
|
||||
JSON.stringify(data.metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra múltiplos eventos em batch
|
||||
*/
|
||||
export async function trackEventsBatch(
|
||||
events: TrackEventData[]
|
||||
): Promise<TelemetryEvent[]> {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const values: any[] = [];
|
||||
const placeholders: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const event of events) {
|
||||
placeholders.push(
|
||||
`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3}, $${paramIndex + 4})`
|
||||
);
|
||||
values.push(
|
||||
event.event_type,
|
||||
event.ad_id || null,
|
||||
event.user_id || null,
|
||||
event.session_id || null,
|
||||
JSON.stringify(event.metadata || {})
|
||||
);
|
||||
paramIndex += 5;
|
||||
}
|
||||
|
||||
const result = await query<TelemetryEvent>(
|
||||
`
|
||||
INSERT INTO telemetry_events (
|
||||
event_type, ad_id, user_id, session_id, metadata
|
||||
)
|
||||
VALUES ${placeholders.join(", ")}
|
||||
RETURNING *
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca analytics de um anúncio específico
|
||||
*/
|
||||
export async function getAdAnalytics(adId: number): Promise<AdAnalytics | null> {
|
||||
const result = await query<AdAnalytics>(
|
||||
`
|
||||
SELECT * FROM ad_analytics
|
||||
WHERE ad_id = $1
|
||||
`,
|
||||
[adId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca comportamento do usuário (comprador)
|
||||
*/
|
||||
export async function getUserBehavior(
|
||||
userId: number,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<UserBehavior | null> {
|
||||
// Verificar se usuário existe
|
||||
const userCheck = await query<{ user_id: number }>(
|
||||
"SELECT user_id FROM users WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateFilter = startDate && endDate
|
||||
? "AND timestamp BETWEEN $2 AND $3"
|
||||
: "";
|
||||
|
||||
const params = [userId];
|
||||
if (startDate && endDate) {
|
||||
params.push(startDate, endDate);
|
||||
}
|
||||
|
||||
// Buscar queries de busca
|
||||
const searchQueries = await query<{ metadata: { query?: string } }>(
|
||||
`
|
||||
SELECT DISTINCT metadata->>'query' as query
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 AND event_type = 'search' ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar filtros aplicados
|
||||
const filters = await query<{ metadata: Record<string, any> }>(
|
||||
`
|
||||
SELECT DISTINCT metadata
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 AND event_type = 'filter' ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar itens visualizados
|
||||
const itemsViewed = await query<{
|
||||
ad_id: number;
|
||||
metadata: { duration?: number };
|
||||
timestamp: Date;
|
||||
}>(
|
||||
`
|
||||
SELECT ad_id, metadata, timestamp
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 AND event_type = 'ad_view' AND ad_id IS NOT NULL ${dateFilter}
|
||||
ORDER BY timestamp DESC
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar favoritos
|
||||
const favorites = await query<{ ad_id: number }>(
|
||||
`
|
||||
SELECT DISTINCT ad_id
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 AND event_type = 'favorite' AND ad_id IS NOT NULL ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar compras
|
||||
const purchases = await query<{
|
||||
ad_id: number;
|
||||
metadata: { price?: number };
|
||||
timestamp: Date;
|
||||
}>(
|
||||
`
|
||||
SELECT ad_id, metadata, timestamp
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 AND event_type = 'purchase' AND ad_id IS NOT NULL ${dateFilter}
|
||||
ORDER BY timestamp DESC
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar horas ativas
|
||||
const activeHours = await query<{
|
||||
hour: number;
|
||||
count: string;
|
||||
}>(
|
||||
`
|
||||
SELECT EXTRACT(HOUR FROM timestamp)::INTEGER as hour, COUNT(*)::TEXT as count
|
||||
FROM telemetry_events
|
||||
WHERE user_id = $1 ${dateFilter}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Buscar item_id dos anúncios visualizados
|
||||
const itemIds = await query<{ item_id: number }>(
|
||||
`
|
||||
SELECT DISTINCT l.item_id
|
||||
FROM telemetry_events te
|
||||
JOIN listings l ON l.listing_id = te.ad_id
|
||||
WHERE te.user_id = $1 AND te.event_type = 'ad_view' AND te.ad_id IS NOT NULL ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
search_queries: searchQueries.rows
|
||||
.map((r) => r.metadata?.query)
|
||||
.filter((q): q is string => !!q),
|
||||
filters_applied: filters.rows.reduce((acc, r) => {
|
||||
return { ...acc, ...r.metadata };
|
||||
}, {}),
|
||||
items_viewed: itemsViewed.rows.map((r) => ({
|
||||
item_id: r.ad_id,
|
||||
duration: r.metadata?.duration || 0,
|
||||
timestamp: r.timestamp,
|
||||
})),
|
||||
items_favorited: favorites.rows.map((r) => r.ad_id),
|
||||
purchases: purchases.rows.map((r) => ({
|
||||
ad_id: r.ad_id,
|
||||
price: r.metadata?.price || 0,
|
||||
timestamp: r.timestamp,
|
||||
})),
|
||||
active_hours: activeHours.rows.map((r) => ({
|
||||
hour: r.hour,
|
||||
activity_count: parseInt(r.count, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca métricas agregadas do marketplace
|
||||
*/
|
||||
export async function getMarketplaceStats(
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<MarketplaceStats> {
|
||||
const dateFilter = startDate && endDate
|
||||
? "WHERE timestamp BETWEEN $1 AND $2"
|
||||
: "";
|
||||
const params = startDate && endDate ? [startDate, endDate] : [];
|
||||
|
||||
// Total de transações
|
||||
const transactions = await query<{ count: string }>(
|
||||
`
|
||||
SELECT COUNT(*)::TEXT as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase' ${dateFilter.replace("timestamp", "timestamp")}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Preço médio por categoria (requer join com listings)
|
||||
const avgPriceByCategory = await query<{
|
||||
category: string;
|
||||
avg_price: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
l.item_name,
|
||||
AVG(COALESCE(l.price_pix, l.price_tibia_coins, l.price_gold_coins))::TEXT as avg_price
|
||||
FROM listings l
|
||||
JOIN telemetry_events te ON te.ad_id = l.listing_id
|
||||
WHERE te.event_type = 'purchase' ${dateFilter.replace("timestamp", "te.timestamp")}
|
||||
GROUP BY l.item_name
|
||||
LIMIT 10
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Taxa de conversão
|
||||
const views = await query<{ count: string }>(
|
||||
`
|
||||
SELECT COUNT(*)::TEXT as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_view' ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
const purchases = await query<{ count: string }>(
|
||||
`
|
||||
SELECT COUNT(*)::TEXT as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase' ${dateFilter}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
const viewsCount = parseInt(views.rows[0]?.count || "0", 10);
|
||||
const purchasesCount = parseInt(purchases.rows[0]?.count || "0", 10);
|
||||
const conversionRate = viewsCount > 0 ? (purchasesCount / viewsCount) * 100 : 0;
|
||||
|
||||
// Itens mais procurados
|
||||
const mostSearched = await query<{
|
||||
item_name: string;
|
||||
search_count: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
metadata->>'query' as item_name,
|
||||
COUNT(*)::TEXT as search_count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'search' AND metadata->>'query' IS NOT NULL ${dateFilter}
|
||||
GROUP BY metadata->>'query'
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Itens mais vendidos
|
||||
const mostSold = await query<{
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
sales_count: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
l.item_id,
|
||||
l.item_name,
|
||||
COUNT(*)::TEXT as sales_count
|
||||
FROM telemetry_events te
|
||||
JOIN listings l ON l.listing_id = te.ad_id
|
||||
WHERE te.event_type = 'purchase' ${dateFilter.replace("timestamp", "te.timestamp")}
|
||||
GROUP BY l.item_id, l.item_name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Horários de pico
|
||||
const peakHours = await query<{
|
||||
hour: number;
|
||||
activity_count: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
|
||||
COUNT(*)::TEXT as activity_count
|
||||
FROM telemetry_events
|
||||
${dateFilter}
|
||||
GROUP BY hour
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// Tempo médio para venda
|
||||
const avgTimeToSell = await query<{
|
||||
avg_hours: string;
|
||||
}>(
|
||||
`
|
||||
SELECT AVG(aa.time_to_sell)::TEXT as avg_hours
|
||||
FROM ad_analytics aa
|
||||
WHERE aa.time_to_sell IS NOT NULL
|
||||
`,
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
total_transactions: parseInt(transactions.rows[0]?.count || "0", 10),
|
||||
avg_price_by_category: avgPriceByCategory.rows.reduce(
|
||||
(acc, r) => {
|
||||
acc[r.category] = parseFloat(r.avg_price);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
conversion_rate: conversionRate,
|
||||
most_searched_items: mostSearched.rows.map((r) => ({
|
||||
item_id: 0, // Não temos item_id em searches, apenas query string
|
||||
item_name: r.item_name || "",
|
||||
search_count: parseInt(r.search_count, 10),
|
||||
})),
|
||||
most_sold_items: mostSold.rows.map((r) => ({
|
||||
item_id: r.item_id,
|
||||
item_name: r.item_name,
|
||||
sales_count: parseInt(r.sales_count, 10),
|
||||
})),
|
||||
peak_hours: peakHours.rows.map((r) => ({
|
||||
hour: r.hour,
|
||||
activity_count: parseInt(r.activity_count, 10),
|
||||
})),
|
||||
avg_time_to_sell: parseFloat(avgTimeToSell.rows[0]?.avg_hours || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega eventos de telemetria em métricas de analytics
|
||||
* Executar periodicamente (ex: a cada hora)
|
||||
*/
|
||||
export async function aggregateHourlyMetrics(): Promise<void> {
|
||||
// Agregar impressões (ad_view events)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO ad_analytics (ad_id, impressions, last_updated)
|
||||
SELECT
|
||||
ad_id,
|
||||
COUNT(*) as impressions,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_view'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
ON CONFLICT (ad_id) DO UPDATE SET
|
||||
impressions = ad_analytics.impressions + EXCLUDED.impressions,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
`
|
||||
);
|
||||
|
||||
// Agregar cliques
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET clicks = clicks + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_click'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`
|
||||
);
|
||||
|
||||
// Atualizar CTR (Click-Through Rate)
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics
|
||||
SET ctr = CASE
|
||||
WHEN impressions > 0 THEN (clicks::DECIMAL / impressions::DECIMAL) * 100
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE impressions > 0
|
||||
`
|
||||
);
|
||||
|
||||
// Agregar duração média de visualização
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
avg_view_duration = COALESCE(
|
||||
(
|
||||
SELECT AVG((metadata->>'duration')::DECIMAL)
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
AND metadata->>'duration' IS NOT NULL
|
||||
),
|
||||
avg_view_duration
|
||||
),
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
// Calcular bounce rate (views com duração < 3 segundos)
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
bounce_rate = CASE
|
||||
WHEN aa.impressions > 0 THEN
|
||||
(
|
||||
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
AND (metadata->>'duration')::DECIMAL < 3
|
||||
)
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE aa.impressions > 0
|
||||
`
|
||||
);
|
||||
|
||||
// Agregar inquiries
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET inquiries = inquiries + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'inquiry'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`
|
||||
);
|
||||
|
||||
// Agregar favoritos
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET favorites = favorites + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'favorite'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`
|
||||
);
|
||||
|
||||
// Calcular taxa de conversão
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
conversion_rate = CASE
|
||||
WHEN aa.impressions > 0 THEN
|
||||
(
|
||||
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
// Calcular time_to_sell para anúncios vendidos
|
||||
await query(
|
||||
`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
time_to_sell = EXTRACT(EPOCH FROM (l.sold_at - l.created_at)) / 3600,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM listings l
|
||||
WHERE aa.ad_id = l.listing_id
|
||||
AND l.listing_status = 'sold'
|
||||
AND l.sold_at IS NOT NULL
|
||||
AND aa.time_to_sell IS NULL
|
||||
AND l.sold_at >= NOW() - INTERVAL '1 hour'
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface Listing {
|
||||
listing_id: number;
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
price_pix: number | string | null;
|
||||
price_tibia_coins: number | string | null;
|
||||
price_gold_coins: number | string | null;
|
||||
is_auction: boolean;
|
||||
description: string | null;
|
||||
listing_status: string;
|
||||
created_at: string;
|
||||
views_count: number;
|
||||
favorites_count: number;
|
||||
is_featured?: boolean;
|
||||
item_id_unified?: number | null;
|
||||
image_local_path?: string | null;
|
||||
image_url?: string | null;
|
||||
item_category?: string | null;
|
||||
seller_character_name?: string | null;
|
||||
}
|
||||
|
||||
interface ListingsResponse {
|
||||
listings: Listing[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListingsParams {
|
||||
search?: string;
|
||||
server_name?: string;
|
||||
price_min?: string;
|
||||
price_max?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar listings do marketplace
|
||||
* Atualiza automaticamente a cada 10 segundos
|
||||
*/
|
||||
export function useListings(params: ListingsParams = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.search) queryParams.set("search", params.search);
|
||||
if (params.server_name) queryParams.set("server_name", params.server_name);
|
||||
if (params.price_min) queryParams.set("price_min", params.price_min);
|
||||
if (params.price_max) queryParams.set("price_max", params.price_max);
|
||||
queryParams.set("sortBy", params.sortBy || "created_at");
|
||||
queryParams.set("sortOrder", params.sortOrder || "DESC");
|
||||
queryParams.set("page", String(params.page || 1));
|
||||
queryParams.set("pageSize", String(params.pageSize || 20));
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<ListingsResponse>({
|
||||
queryKey: ["listings", params],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/listings?${queryParams.toString()}`);
|
||||
if (!res.ok) throw new Error("Erro ao buscar anúncios");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 1000 * 10, // 10 segundos
|
||||
refetchInterval: 1000 * 10, // Refetch a cada 10 segundos
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
listings: data?.listings || [],
|
||||
total: data?.pagination?.total || 0,
|
||||
page: data?.pagination?.page || params.page || 1,
|
||||
pageSize: data?.pagination?.pageSize || params.pageSize || 20,
|
||||
totalPages: data?.pagination?.totalPages || 1,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar meus anúncios
|
||||
* Atualiza automaticamente a cada 10 segundos
|
||||
*/
|
||||
export function useMyListings(status?: string) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (status) queryParams.set("status", status);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<ListingsResponse>({
|
||||
queryKey: ["my-listings", status],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/listings/my?${queryParams.toString()}`);
|
||||
if (!res.ok) throw new Error("Erro ao buscar meus anúncios");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 1000 * 10, // 10 segundos
|
||||
refetchInterval: 1000 * 10, // Refetch a cada 10 segundos
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
enabled: true, // Sempre habilitado (autenticação é verificada no backend)
|
||||
});
|
||||
|
||||
return {
|
||||
listings: data?.listings || [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para invalidar cache de listings
|
||||
*/
|
||||
export function useInvalidateListings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
|
||||
};
|
||||
|
||||
return invalidate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface SessionUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
user?: SessionUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para gerenciar sessão do usuário com React Query
|
||||
* Atualiza automaticamente a cada 30 segundos
|
||||
*/
|
||||
export function useSession() {
|
||||
const { data, isLoading, refetch } = useQuery<SessionResponse>({
|
||||
queryKey: ["session"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 1000 * 30, // 30 segundos
|
||||
refetchInterval: 1000 * 30, // Refetch a cada 30 segundos
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
// Invalidar cache e recarregar
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer logout:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
user: data?.user || null,
|
||||
isLoading,
|
||||
isAuthenticated: data?.authenticated || false,
|
||||
logout,
|
||||
refreshSession: refetch,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-102
@@ -1,105 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export interface SessionUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
user: SessionUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para gerenciar sessão do usuário
|
||||
*/
|
||||
export function useSession() {
|
||||
const [session, setSession] = useState<SessionState>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifica a sessão atual
|
||||
*/
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.authenticated) {
|
||||
setSession({
|
||||
user: data.user,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao verificar sessão:", error);
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Encerra a sessão
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
// Recarregar página para limpar estado
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer logout:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Atualiza a sessão após login
|
||||
*/
|
||||
const refreshSession = useCallback(async () => {
|
||||
await checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
// Verificar sessão ao montar componente
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
isLoading: session.isLoading,
|
||||
isAuthenticated: session.isAuthenticated,
|
||||
logout,
|
||||
refreshSession,
|
||||
};
|
||||
}
|
||||
// Re-exportar o hook que usa React Query
|
||||
export { useSession, type SessionUser } from "./use-session-query";
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Hooks para tracking de telemetria
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { getTelemetry } from "../telemetry/MarketTelemetry";
|
||||
import { useSession } from "./use-session";
|
||||
|
||||
/**
|
||||
* Hook para tracking automático de visualização de anúncio
|
||||
*/
|
||||
export function useAdTracking(adId: number | null) {
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const { user } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!adId) return;
|
||||
|
||||
const telemetry = getTelemetry(user?.userId);
|
||||
startTimeRef.current = Date.now();
|
||||
telemetry.trackAdView(adId);
|
||||
|
||||
return () => {
|
||||
if (startTimeRef.current && adId) {
|
||||
telemetry.trackAdExit(adId);
|
||||
}
|
||||
};
|
||||
}, [adId, user?.userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para tracking global do marketplace
|
||||
*/
|
||||
export function useMarketTelemetry() {
|
||||
const { user } = useSession();
|
||||
const telemetryRef = useRef(getTelemetry(user?.userId));
|
||||
|
||||
// Atualizar user_id quando mudar
|
||||
useEffect(() => {
|
||||
if (user?.userId) {
|
||||
telemetryRef.current.setUserId(user.userId);
|
||||
}
|
||||
}, [user?.userId]);
|
||||
|
||||
// Cleanup ao desmontar
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
telemetryRef.current.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trackSearch = useCallback((query: string, filters?: Record<string, any>) => {
|
||||
telemetryRef.current.trackSearch(query, filters);
|
||||
}, []);
|
||||
|
||||
const trackFilter = useCallback((filters: Record<string, any>) => {
|
||||
telemetryRef.current.trackFilterApplied(filters);
|
||||
}, []);
|
||||
|
||||
const trackFavorite = useCallback((adId: number, action: "favorite" | "unfavorite") => {
|
||||
telemetryRef.current.trackFavorite(adId, action);
|
||||
}, []);
|
||||
|
||||
const trackPurchase = useCallback((adId: number, price: number) => {
|
||||
telemetryRef.current.trackPurchase(adId, price);
|
||||
}, []);
|
||||
|
||||
const trackInquiry = useCallback((adId: number) => {
|
||||
telemetryRef.current.trackInquiry(adId);
|
||||
}, []);
|
||||
|
||||
const trackPageView = useCallback((page: string) => {
|
||||
telemetryRef.current.trackPageView(page);
|
||||
}, []);
|
||||
|
||||
const trackAdClick = useCallback((adId: number, position?: number) => {
|
||||
telemetryRef.current.trackAdClick(adId, position);
|
||||
}, []);
|
||||
|
||||
const trackCustomEvent = useCallback((
|
||||
eventType: string,
|
||||
adId?: number,
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
telemetryRef.current.trackCustomEvent(eventType, adId, metadata);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackSearch,
|
||||
trackFilter,
|
||||
trackFavorite,
|
||||
trackPurchase,
|
||||
trackInquiry,
|
||||
trackPageView,
|
||||
trackAdClick,
|
||||
trackCustomEvent,
|
||||
telemetry: telemetryRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,4 +231,52 @@ export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por ID no banco de dados
|
||||
* Busca em todas as tabelas de categorias até encontrar o item
|
||||
*/
|
||||
export function getItemById(itemId: number, itemName?: string): any | null {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Buscar em todas as tabelas de categorias
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
let item: any = null;
|
||||
|
||||
// Se itemName foi fornecido, buscar por ID E NOME (case-insensitive)
|
||||
if (itemName) {
|
||||
item = db.prepare(`SELECT * FROM ${table.name} WHERE id = ? AND LOWER(nome) = LOWER(?)`).get(itemId, itemName);
|
||||
} else {
|
||||
// Se não há nome, buscar apenas por ID (comportamento antigo)
|
||||
item = db.prepare(`SELECT * FROM ${table.name} WHERE id = ?`).get(itemId);
|
||||
}
|
||||
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignorar erros de tabelas que não têm coluna 'id' ou 'nome'
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[items-db] Erro ao buscar item por ID:", error);
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Prisma Client para PostgreSQL (Banco Principal da Aplicação)
|
||||
* Usa DATABASE_URL_APP do .env
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Queries para banco SQLite de itens usando Prisma
|
||||
* Como o banco tem tabelas dinâmicas (uma por categoria),
|
||||
* usamos $queryRaw para queries dinâmicas
|
||||
*/
|
||||
|
||||
import { itemsPrisma } from "./items";
|
||||
import { enToPtCategoryMap } from "../item-images";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export interface SearchParams {
|
||||
q?: string;
|
||||
categories?: string[];
|
||||
rarities?: string[];
|
||||
sort?:
|
||||
| "name"
|
||||
| "level-asc"
|
||||
| "level-desc"
|
||||
| "weight-asc"
|
||||
| "weight-desc"
|
||||
| "category";
|
||||
vocation?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
let schemaLogged = false;
|
||||
|
||||
// Tenta deduzir ordenação segura a partir do parâmetro de sort
|
||||
function getOrderBy(sort: SearchParams["sort"]): string {
|
||||
switch (sort) {
|
||||
case "level-asc":
|
||||
return "level ASC, name ASC";
|
||||
case "level-desc":
|
||||
return "level DESC, name ASC";
|
||||
case "weight-asc":
|
||||
return "weight ASC, name ASC";
|
||||
case "weight-desc":
|
||||
return "weight DESC, name ASC";
|
||||
case "category":
|
||||
return "category ASC, name ASC";
|
||||
case "name":
|
||||
default:
|
||||
return "name ASC";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca itens no banco SQLite usando Prisma $queryRaw
|
||||
*/
|
||||
export async function searchItems<T = any>(
|
||||
params: SearchParams
|
||||
): Promise<SearchResult<T>> {
|
||||
const {
|
||||
q = "",
|
||||
categories = [],
|
||||
rarities = [],
|
||||
sort = "name",
|
||||
vocation,
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
} = params;
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(Math.max(1, pageSize), 50);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
try {
|
||||
// Log básico do schema na primeira chamada
|
||||
if (!schemaLogged) {
|
||||
try {
|
||||
const tables = await itemsPrisma.$queryRaw<
|
||||
Array<{ name: string }>
|
||||
>`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`;
|
||||
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
|
||||
schemaLogged = true;
|
||||
} catch (intErr) {
|
||||
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
|
||||
schemaLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar todas as tabelas disponíveis
|
||||
const allTables = await itemsPrisma.$queryRaw<
|
||||
Array<{ name: string }>
|
||||
>`SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'`;
|
||||
|
||||
// Filtra por categorias solicitadas (EN → PT)
|
||||
const allowedPt = new Set(
|
||||
categories.length > 0
|
||||
? categories.map((en) => enToPtCategoryMap[en] || en)
|
||||
: allTables.map((t) => t.name)
|
||||
);
|
||||
const candidateTables = allTables
|
||||
.map((t) => t.name)
|
||||
.filter((name) => allowedPt.has(name));
|
||||
|
||||
const selects: string[] = [];
|
||||
const bindings: any[] = [];
|
||||
|
||||
for (const tableName of candidateTables) {
|
||||
try {
|
||||
// Verificar colunas da tabela
|
||||
const cols = await itemsPrisma.$queryRaw<
|
||||
Array<{ name: string; type: string }>
|
||||
>`PRAGMA table_info(${Prisma.raw(tableName)})`;
|
||||
|
||||
const colNames = new Set(cols.map((c) => (c?.name || "").toLowerCase()));
|
||||
|
||||
// Requer colunas mínimas
|
||||
if (
|
||||
!colNames.has("id") ||
|
||||
!colNames.has("nome") ||
|
||||
!colNames.has("image_url")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Se filtro de vocação foi solicitado e a tabela não possui coluna 'voc', ignora
|
||||
if (vocation && !colNames.has("voc")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableWhere: string[] = [];
|
||||
const tableBindings: any[] = [];
|
||||
|
||||
if (q && colNames.has("nome")) {
|
||||
tableWhere.push("LOWER(nome) LIKE ?");
|
||||
tableBindings.push(`%${q.toLowerCase()}%`);
|
||||
}
|
||||
|
||||
if (vocation && colNames.has("voc")) {
|
||||
const sel = vocation.toLowerCase();
|
||||
tableWhere.push("REPLACE(LOWER(voc), ' ', '') = REPLACE(?, ' ', '')");
|
||||
tableBindings.push(sel);
|
||||
}
|
||||
|
||||
const whereSqlTbl =
|
||||
tableWhere.length > 0 ? `WHERE ${tableWhere.join(" AND ")}` : "";
|
||||
|
||||
// Mapear colunas dinâmicas
|
||||
const levelCol =
|
||||
colNames.has("nivel") || colNames.has("lvl")
|
||||
? `COALESCE(${colNames.has("nivel") ? "nivel" : "NULL"}, ${colNames.has("lvl") ? "lvl" : "NULL"})`
|
||||
: "NULL";
|
||||
const weightCol =
|
||||
colNames.has("peso") || colNames.has("peso_em_oz")
|
||||
? `COALESCE(${colNames.has("peso") ? "peso" : "NULL"}, ${colNames.has("peso_em_oz") ? "peso_em_oz" : "NULL"})`
|
||||
: "NULL";
|
||||
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
|
||||
const skillCol = colNames.has("skill_para_usar")
|
||||
? "skill_para_usar"
|
||||
: "NULL";
|
||||
|
||||
// Campos extras opcionais
|
||||
const extraCols = [
|
||||
"lvl",
|
||||
"voc",
|
||||
"volume",
|
||||
"bonus_de_skill",
|
||||
"protecao",
|
||||
"dropa_de",
|
||||
] as const;
|
||||
const extraSelect = extraCols
|
||||
.map((c) => (colNames.has(c) ? `${c}` : `NULL AS ${c}`))
|
||||
.join(", ");
|
||||
|
||||
const selectSql = `SELECT
|
||||
id,
|
||||
nome AS name,
|
||||
'${tableName}' AS category,
|
||||
image_url AS image,
|
||||
${levelCol} AS level,
|
||||
${weightCol} AS weight,
|
||||
${slotsCol} AS slots,
|
||||
${skillCol} AS skill${extraSelect ? ", " + extraSelect : ""}
|
||||
FROM ${tableName} ${whereSqlTbl}`;
|
||||
|
||||
selects.push(selectSql);
|
||||
bindings.push(...tableBindings);
|
||||
} catch (e) {
|
||||
console.warn(`[items-db] Ignorando tabela ${tableName}:`, (e as any)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selects.length === 0) {
|
||||
console.warn("[items-db] Nenhuma tabela compatível encontrada");
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
|
||||
const unionSql = selects.join(" UNION ALL ");
|
||||
const orderBy = getOrderBy(sort);
|
||||
|
||||
// Contagem total
|
||||
const countSql = `SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`;
|
||||
const countResult = await itemsPrisma.$queryRaw<
|
||||
Array<{ cnt: number }>
|
||||
>(Prisma.raw(countSql), ...bindings);
|
||||
const total = countResult[0]?.cnt ?? 0;
|
||||
|
||||
// Buscar itens com paginação
|
||||
const selectSql = `SELECT * FROM (${unionSql}) AS u ORDER BY ${orderBy} LIMIT ? OFFSET ?`;
|
||||
const items = await itemsPrisma.$queryRaw<T>(
|
||||
Prisma.raw(selectSql),
|
||||
...bindings,
|
||||
safePageSize,
|
||||
offset
|
||||
);
|
||||
|
||||
return { items, total };
|
||||
} catch (error) {
|
||||
console.error("[items-db] Erro ao buscar itens:", error);
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por ID no banco de dados
|
||||
* Busca em todas as tabelas de categorias até encontrar o item
|
||||
*/
|
||||
export async function getItemById(itemId: number): Promise<any | null> {
|
||||
try {
|
||||
// Buscar em todas as tabelas de categorias
|
||||
const tables = await itemsPrisma.$queryRaw<
|
||||
Array<{ name: string }>
|
||||
>`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const item = await itemsPrisma.$queryRaw<any>(
|
||||
Prisma.raw(`SELECT * FROM ${table.name} WHERE id = ?`),
|
||||
itemId
|
||||
);
|
||||
|
||||
if (item && item.length > 0) {
|
||||
return item[0];
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignorar erros de tabelas que não têm coluna 'id' ou outros problemas
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[items-db] Erro ao buscar item por ID:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Prisma Client para SQLite (Banco de Itens do TibiaWiki)
|
||||
*
|
||||
* NOTA: Por enquanto, mantemos better-sqlite3 para queries SQLite
|
||||
* devido às limitações do Prisma com tabelas dinâmicas.
|
||||
*
|
||||
* Este arquivo está preparado para migração futura quando necessário.
|
||||
*/
|
||||
|
||||
// Por enquanto, não exportamos nada - usando better-sqlite3 diretamente
|
||||
// export const itemsPrisma = ... será implementado quando migrarmos SQLite
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Queries de bids usando Prisma
|
||||
* Migrado de lib/db/queries/bids.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
export interface Bid {
|
||||
bid_id: number;
|
||||
listing_id: number;
|
||||
bidder_id: number;
|
||||
bid_amount: number;
|
||||
bid_type: "pix" | "tibia_coins" | "gold_coins" | null;
|
||||
created_at: Date;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBidData {
|
||||
listing_id: number;
|
||||
bidder_id: number;
|
||||
bid_amount: number;
|
||||
bid_type?: "pix" | "tibia_coins" | "gold_coins";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo lance
|
||||
*/
|
||||
export async function createBid(data: CreateBidData): Promise<Bid> {
|
||||
return await prisma.bid.create({
|
||||
data: {
|
||||
listing_id: data.listing_id,
|
||||
bidder_id: data.bidder_id,
|
||||
bid_amount: data.bid_amount,
|
||||
bid_type: data.bid_type || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca lances de um anúncio
|
||||
*/
|
||||
export async function getBidsByListing(
|
||||
listingId: number,
|
||||
activeOnly: boolean = true
|
||||
): Promise<Bid[]> {
|
||||
return await prisma.bid.findMany({
|
||||
where: {
|
||||
listing_id: listingId,
|
||||
...(activeOnly ? { is_active: true } : {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ bid_amount: "desc" },
|
||||
{ created_at: "desc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca o maior lance de um anúncio
|
||||
*/
|
||||
export async function getHighestBid(listingId: number): Promise<Bid | null> {
|
||||
return await prisma.bid.findFirst({
|
||||
where: {
|
||||
listing_id: listingId,
|
||||
is_active: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ bid_amount: "desc" },
|
||||
{ created_at: "desc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela um lance (desativa)
|
||||
*/
|
||||
export async function cancelBid(bidId: number): Promise<boolean> {
|
||||
const result = await prisma.bid.updateMany({
|
||||
where: { bid_id: bidId },
|
||||
data: { is_active: false },
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca lance por ID
|
||||
*/
|
||||
export async function getBidById(bidId: number): Promise<Bid | null> {
|
||||
return await prisma.bid.findUnique({
|
||||
where: { bid_id: bidId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Queries de personagens usando Prisma
|
||||
* Migrado de lib/db/queries/characters.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
export interface Character {
|
||||
character_id: number;
|
||||
user_id: number;
|
||||
character_name: string;
|
||||
server_name: string;
|
||||
is_main: boolean;
|
||||
verified: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateCharacterData {
|
||||
character_name: string;
|
||||
server_name: string;
|
||||
is_main?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCharacterData {
|
||||
character_name?: string;
|
||||
server_name?: string;
|
||||
is_main?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo personagem
|
||||
* Se isMain=true, desmarca outros personagens como main
|
||||
*/
|
||||
export async function createCharacter(
|
||||
userId: number,
|
||||
data: CreateCharacterData
|
||||
): Promise<Character> {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Se este personagem será main, desmarcar outros
|
||||
if (data.is_main) {
|
||||
await tx.userCharacter.updateMany({
|
||||
where: { user_id: userId },
|
||||
data: { is_main: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Inserir novo personagem
|
||||
return await tx.userCharacter.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
character_name: data.character_name,
|
||||
server_name: data.server_name,
|
||||
is_main: data.is_main || false,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os personagens de um usuário
|
||||
*/
|
||||
export async function getUserCharacters(userId: number): Promise<Character[]> {
|
||||
return await prisma.userCharacter.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: [
|
||||
{ is_main: "desc" },
|
||||
{ created_at: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca personagem principal de um usuário em um servidor específico
|
||||
*/
|
||||
export async function getMainCharacterByUserId(
|
||||
userId: number,
|
||||
serverName: string
|
||||
): Promise<Character | null> {
|
||||
return await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: userId,
|
||||
server_name: serverName,
|
||||
},
|
||||
orderBy: [
|
||||
{ is_main: "desc" },
|
||||
{ created_at: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca personagem por ID
|
||||
*/
|
||||
export async function getCharacterById(
|
||||
characterId: number
|
||||
): Promise<Character | null> {
|
||||
return await prisma.userCharacter.findUnique({
|
||||
where: { character_id: characterId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza personagem
|
||||
* Verifica ownership e se isMain=true, desmarca outros
|
||||
*/
|
||||
export async function updateCharacter(
|
||||
characterId: number,
|
||||
userId: number,
|
||||
data: UpdateCharacterData
|
||||
): Promise<Character> {
|
||||
// Verificar ownership primeiro
|
||||
const existing = await getCharacterById(characterId);
|
||||
if (!existing) {
|
||||
throw new Error("Personagem não encontrado");
|
||||
}
|
||||
|
||||
if (existing.user_id !== userId) {
|
||||
throw new Error("Você não tem permissão para atualizar este personagem");
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Se este personagem será main, desmarcar outros
|
||||
if (data.is_main) {
|
||||
await tx.userCharacter.updateMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
character_id: { not: characterId },
|
||||
},
|
||||
data: { is_main: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Atualizar personagem
|
||||
const updateData: any = {};
|
||||
if (data.character_name !== undefined) updateData.character_name = data.character_name;
|
||||
if (data.server_name !== undefined) updateData.server_name = data.server_name;
|
||||
if (data.is_main !== undefined) updateData.is_main = data.is_main;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return await tx.userCharacter.update({
|
||||
where: { character_id: characterId },
|
||||
data: updateData,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove personagem
|
||||
* Verifica ownership antes de remover
|
||||
*/
|
||||
export async function deleteCharacter(
|
||||
characterId: number,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
// Verificar ownership
|
||||
const existing = await getCharacterById(characterId);
|
||||
if (!existing) {
|
||||
throw new Error("Personagem não encontrado");
|
||||
}
|
||||
|
||||
if (existing.user_id !== userId) {
|
||||
throw new Error("Você não tem permissão para remover este personagem");
|
||||
}
|
||||
|
||||
await prisma.userCharacter.delete({
|
||||
where: { character_id: characterId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Queries de favoritos usando Prisma
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
/**
|
||||
* Verifica se um anúncio está favoritado por um usuário
|
||||
*/
|
||||
export async function isFavorite(userId: number, listingId: number): Promise<boolean> {
|
||||
const favorite = await prisma.userFavorite.findUnique({
|
||||
where: {
|
||||
user_id_ad_id: {
|
||||
user_id: userId,
|
||||
ad_id: listingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!favorite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorita um anúncio
|
||||
*/
|
||||
export async function addFavorite(userId: number, listingId: number): Promise<void> {
|
||||
await prisma.userFavorite.upsert({
|
||||
where: {
|
||||
user_id_ad_id: {
|
||||
user_id: userId,
|
||||
ad_id: listingId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
user_id: userId,
|
||||
ad_id: listingId,
|
||||
},
|
||||
update: {}, // Se já existe, não faz nada
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove favorito de um anúncio
|
||||
*/
|
||||
export async function removeFavorite(userId: number, listingId: number): Promise<void> {
|
||||
await prisma.userFavorite.delete({
|
||||
where: {
|
||||
user_id_ad_id: {
|
||||
user_id: userId,
|
||||
ad_id: listingId,
|
||||
},
|
||||
},
|
||||
}).catch(() => {
|
||||
// Ignorar erro se não existir (idempotente)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite (favorita se não estiver, desfavorita se estiver)
|
||||
*/
|
||||
export async function toggleFavorite(userId: number, listingId: number): Promise<boolean> {
|
||||
const isFavorited = await isFavorite(userId, listingId);
|
||||
|
||||
if (isFavorited) {
|
||||
await removeFavorite(userId, listingId);
|
||||
return false;
|
||||
} else {
|
||||
await addFavorite(userId, listingId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Queries de itens usando Prisma
|
||||
* Busca itens da tabela unificada items (PostgreSQL)
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export interface Item {
|
||||
item_id: number;
|
||||
source_table_id: number;
|
||||
source_table_name: string;
|
||||
item_name: string;
|
||||
category: string;
|
||||
image_local_path: string | null;
|
||||
image_url: string | null;
|
||||
rarity: string | null;
|
||||
level: number | null;
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
metadata: any;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface SearchItemsParams {
|
||||
q?: string;
|
||||
category?: string;
|
||||
rarity?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por ID unificado
|
||||
*/
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { item_id: itemId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por nome (exato ou similar)
|
||||
*/
|
||||
export async function getItemByName(itemName: string): Promise<Item | null> {
|
||||
const item = await prisma.item.findFirst({
|
||||
where: {
|
||||
item_name: {
|
||||
equals: itemName,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca itens com filtros
|
||||
*/
|
||||
export async function searchItems(
|
||||
params: SearchItemsParams = {}
|
||||
): Promise<{ items: Item[]; total: number }> {
|
||||
const {
|
||||
q = "",
|
||||
category,
|
||||
rarity,
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
} = params;
|
||||
|
||||
const where: Prisma.ItemWhereInput = {};
|
||||
|
||||
if (q) {
|
||||
where.item_name = {
|
||||
contains: q,
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (rarity) {
|
||||
where.rarity = rarity;
|
||||
}
|
||||
|
||||
const total = await prisma.item.count({ where });
|
||||
|
||||
const items = await prisma.item.findMany({
|
||||
where,
|
||||
orderBy: { item_name: "asc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
})) as Item[],
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca item por source_table_name e source_table_id
|
||||
* (útil para migração ou compatibilidade com IDs antigos)
|
||||
*/
|
||||
export async function getItemBySource(
|
||||
sourceTableName: string,
|
||||
sourceTableId: number
|
||||
): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: {
|
||||
source_table_name_source_table_id: {
|
||||
source_table_name: sourceTableName,
|
||||
source_table_id: sourceTableId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Queries de listings (anúncios) usando Prisma
|
||||
* Migrado de lib/db/queries/listings.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
// Re-exportar tipos para compatibilidade
|
||||
export type ListingStatus = "active" | "sold" | "cancelled" | "in_escrow" | "expired";
|
||||
|
||||
export interface Listing {
|
||||
listing_id: number;
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
price_pix: number | null;
|
||||
price_tibia_coins: number | null;
|
||||
price_gold_coins: number | null;
|
||||
is_auction: boolean;
|
||||
current_bid_amount: number | null;
|
||||
current_bidder_id: number | null;
|
||||
auction_ends_at: Date | null;
|
||||
description: string | null;
|
||||
listing_status: ListingStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
sold_at: Date | null;
|
||||
views_count: number;
|
||||
is_featured?: boolean;
|
||||
favorites_count?: number;
|
||||
inquiries_count?: number;
|
||||
price_history?: any;
|
||||
expires_at?: Date | null;
|
||||
trade_item_id?: number | null;
|
||||
trade_item_name?: string | null;
|
||||
trade_item_quantity?: number | null;
|
||||
min_bid_increment?: number | null;
|
||||
initial_price_pix?: number | null;
|
||||
initial_price_tibia_coins?: number | null;
|
||||
initial_price_gold_coins?: number | null;
|
||||
seller_character_name?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateListingData {
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
price_pix?: number;
|
||||
price_tibia_coins?: number;
|
||||
price_gold_coins?: number;
|
||||
is_auction?: boolean;
|
||||
auction_ends_at?: Date;
|
||||
description?: string;
|
||||
trade_item_id?: number | null;
|
||||
trade_item_name?: string | null;
|
||||
trade_item_quantity?: number | null;
|
||||
min_bid_increment?: number;
|
||||
}
|
||||
|
||||
export interface UpdateListingData {
|
||||
quantity?: number;
|
||||
price_pix?: number;
|
||||
price_tibia_coins?: number;
|
||||
price_gold_coins?: number;
|
||||
description?: string;
|
||||
listing_status?: ListingStatus;
|
||||
current_bid_amount?: number;
|
||||
current_bidder_id?: number;
|
||||
}
|
||||
|
||||
export interface ListingFilters {
|
||||
status?: ListingStatus;
|
||||
server_name?: string;
|
||||
item_id?: number;
|
||||
seller_id?: number;
|
||||
is_auction?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: "created_at" | "price_pix" | "price_tibia_coins" | "price_gold_coins" | "views_count";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo anúncio
|
||||
*/
|
||||
export async function createListing(
|
||||
data: CreateListingData
|
||||
): Promise<Listing> {
|
||||
// Calcular incremento mÃnimo se for leilão e não foi fornecido
|
||||
let minBidIncrement = data.min_bid_increment;
|
||||
if (data.is_auction && !minBidIncrement) {
|
||||
const initialPrice = data.price_pix || 0;
|
||||
minBidIncrement = Math.max(initialPrice * 0.05, 10.0);
|
||||
}
|
||||
|
||||
const listing = await prisma.listing.create({
|
||||
data: {
|
||||
seller_id: data.seller_id,
|
||||
item_id: data.item_id,
|
||||
item_name: data.item_name,
|
||||
quantity: data.quantity,
|
||||
server_name: data.server_name,
|
||||
price_pix: data.price_pix || null,
|
||||
price_tibia_coins: data.price_tibia_coins || null,
|
||||
price_gold_coins: data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
|
||||
is_auction: data.is_auction || false,
|
||||
auction_ends_at: data.auction_ends_at || null,
|
||||
description: data.description || null,
|
||||
trade_item_id: data.trade_item_id || null,
|
||||
trade_item_name: data.trade_item_name || null,
|
||||
trade_item_quantity: data.trade_item_quantity || null,
|
||||
min_bid_increment: minBidIncrement || null,
|
||||
initial_price_pix: data.is_auction ? (data.price_pix || null) : null,
|
||||
initial_price_tibia_coins: data.is_auction ? (data.price_tibia_coins || null) : null,
|
||||
initial_price_gold_coins: data.is_auction && data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Buscar character name separadamente
|
||||
const character = await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: data.seller_id,
|
||||
server_name: data.server_name,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
return {
|
||||
...listing,
|
||||
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
|
||||
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
|
||||
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
|
||||
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
|
||||
seller_character_name: character?.character_name || null,
|
||||
} as Listing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca anúncio por ID
|
||||
*/
|
||||
export async function getListingById(listingId: number): Promise<Listing | null> {
|
||||
const listing = await prisma.listing.findUnique({
|
||||
where: { listing_id: listingId },
|
||||
});
|
||||
|
||||
if (!listing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buscar character name separadamente
|
||||
const character = await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: listing.seller_id,
|
||||
server_name: listing.server_name,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
return {
|
||||
...listing,
|
||||
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
|
||||
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
|
||||
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
|
||||
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
|
||||
seller_character_name: character?.character_name || null,
|
||||
} as Listing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista anúncios com filtros e paginação
|
||||
*/
|
||||
export async function getListings(
|
||||
filters: ListingFilters = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
const {
|
||||
status,
|
||||
server_name,
|
||||
item_id,
|
||||
seller_id,
|
||||
is_auction,
|
||||
search,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
} = filters;
|
||||
|
||||
const where: Prisma.ListingWhereInput = {};
|
||||
|
||||
if (status) {
|
||||
where.listing_status = status;
|
||||
}
|
||||
|
||||
if (server_name) {
|
||||
where.server_name = server_name;
|
||||
}
|
||||
|
||||
if (item_id) {
|
||||
where.item_id = item_id;
|
||||
}
|
||||
|
||||
if (seller_id) {
|
||||
where.seller_id = seller_id;
|
||||
}
|
||||
|
||||
if (is_auction !== undefined) {
|
||||
where.is_auction = is_auction;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ item_name: { contains: search, mode: "insensitive" } },
|
||||
{ description: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Validar sortBy
|
||||
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
|
||||
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
|
||||
const orderBy: Prisma.ListingOrderByWithRelationInput = {
|
||||
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
|
||||
};
|
||||
|
||||
// Contar total
|
||||
const total = await prisma.listing.count({ where });
|
||||
|
||||
// Buscar listings com paginação
|
||||
const listings = await prisma.listing.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
// Buscar character names para cada listing (otimizado com uma query por seller_id + server_name)
|
||||
const sellerServerPairs = new Set(
|
||||
listings.map((l) => `${l.seller_id}:${l.server_name}`)
|
||||
);
|
||||
|
||||
const charactersMap = new Map<string, string>();
|
||||
for (const pair of sellerServerPairs) {
|
||||
const [sellerId, serverName] = pair.split(":");
|
||||
const character = await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: parseInt(sellerId, 10),
|
||||
server_name: serverName,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
if (character) {
|
||||
charactersMap.set(pair, character.character_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
const listingsWithCharacterNames = listings.map((listing) => {
|
||||
const key = `${listing.seller_id}:${listing.server_name}`;
|
||||
return {
|
||||
...listing,
|
||||
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
|
||||
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
|
||||
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
|
||||
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
|
||||
seller_character_name: charactersMap.get(key) || null,
|
||||
} as Listing;
|
||||
});
|
||||
|
||||
return {
|
||||
listings: listingsWithCharacterNames,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza anúncio
|
||||
*/
|
||||
export async function updateListing(
|
||||
listingId: number,
|
||||
data: UpdateListingData
|
||||
): Promise<Listing | null> {
|
||||
const updateData: Prisma.ListingUpdateInput = {};
|
||||
|
||||
if (data.quantity !== undefined) updateData.quantity = data.quantity;
|
||||
if (data.price_pix !== undefined) updateData.price_pix = data.price_pix;
|
||||
if (data.price_tibia_coins !== undefined) updateData.price_tibia_coins = data.price_tibia_coins;
|
||||
if (data.price_gold_coins !== undefined) updateData.price_gold_coins = BigInt(data.price_gold_coins);
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.listing_status !== undefined) updateData.listing_status = data.listing_status;
|
||||
if (data.current_bid_amount !== undefined) updateData.current_bid_amount = data.current_bid_amount;
|
||||
if (data.current_bidder_id !== undefined) {
|
||||
updateData.current_bidder_id = data.current_bidder_id;
|
||||
}
|
||||
|
||||
const listing = await prisma.listing.update({
|
||||
where: { listing_id: listingId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Buscar character name
|
||||
const character = await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: listing.seller_id,
|
||||
server_name: listing.server_name,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
return {
|
||||
...listing,
|
||||
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
|
||||
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
|
||||
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
|
||||
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
|
||||
seller_character_name: character?.character_name || null,
|
||||
} as Listing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove anúncio
|
||||
*/
|
||||
export async function deleteListing(listingId: number): Promise<boolean> {
|
||||
try {
|
||||
await prisma.listing.delete({
|
||||
where: { listing_id: listingId },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa contador de visualizações
|
||||
*/
|
||||
export async function incrementViews(listingId: number): Promise<void> {
|
||||
await prisma.listing.update({
|
||||
where: { listing_id: listingId },
|
||||
data: {
|
||||
views_count: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista anúncios de um vendedor
|
||||
*/
|
||||
export async function getListingsBySeller(
|
||||
sellerId: number,
|
||||
filters: { status?: ListingStatus; page?: number; pageSize?: number } = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
return getListings({
|
||||
seller_id: sellerId,
|
||||
status: filters.status,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista apenas anúncios ativos (incluindo vendidos nas últimas 1 hora)
|
||||
*/
|
||||
export async function getActiveListings(
|
||||
filters: Omit<ListingFilters, "status"> = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
const {
|
||||
server_name,
|
||||
item_id,
|
||||
seller_id,
|
||||
is_auction,
|
||||
search,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
} = filters;
|
||||
|
||||
const where: Prisma.ListingWhereInput = {
|
||||
OR: [
|
||||
// Anúncios ativos
|
||||
{ listing_status: "active" },
|
||||
// Anúncios vendidos nas últimas 1 hora
|
||||
{
|
||||
listing_status: "sold",
|
||||
sold_at: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000), // 1 hora atrás
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (server_name) {
|
||||
where.server_name = server_name;
|
||||
}
|
||||
|
||||
if (item_id) {
|
||||
where.item_id = item_id;
|
||||
}
|
||||
|
||||
if (seller_id) {
|
||||
where.seller_id = seller_id;
|
||||
}
|
||||
|
||||
if (is_auction !== undefined) {
|
||||
where.is_auction = is_auction;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const searchCondition = {
|
||||
OR: [
|
||||
{ item_name: { contains: search, mode: "insensitive" } },
|
||||
{ description: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
};
|
||||
// Combinar com a condição OR existente
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{ listing_status: "active" },
|
||||
{
|
||||
listing_status: "sold",
|
||||
sold_at: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
searchCondition,
|
||||
];
|
||||
delete where.OR;
|
||||
}
|
||||
|
||||
// Validar sortBy
|
||||
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
|
||||
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
|
||||
const orderBy: Prisma.ListingOrderByWithRelationInput = {
|
||||
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
|
||||
};
|
||||
|
||||
// Contar total
|
||||
const total = await prisma.listing.count({ where });
|
||||
|
||||
// Buscar listings com paginação
|
||||
const listings = await prisma.listing.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
// Buscar character names para cada listing
|
||||
const sellerServerPairs = new Set(
|
||||
listings.map((l) => `${l.seller_id}:${l.server_name}`)
|
||||
);
|
||||
|
||||
const charactersMap = new Map<string, string>();
|
||||
for (const pair of sellerServerPairs) {
|
||||
const [sellerId, serverName] = pair.split(":");
|
||||
const character = await prisma.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: parseInt(sellerId, 10),
|
||||
server_name: serverName,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
if (character) {
|
||||
charactersMap.set(pair, character.character_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
const listingsWithCharacterNames = listings.map((listing) => {
|
||||
const key = `${listing.seller_id}:${listing.server_name}`;
|
||||
return {
|
||||
...listing,
|
||||
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
|
||||
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
|
||||
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
|
||||
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
|
||||
seller_character_name: charactersMap.get(key) || null,
|
||||
} as Listing;
|
||||
});
|
||||
|
||||
return {
|
||||
listings: listingsWithCharacterNames,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coloca um lance em um leilão
|
||||
*/
|
||||
export async function placeBid(
|
||||
listingId: number,
|
||||
bidderId: number,
|
||||
amount: number,
|
||||
paymentMethod: "pix" | "tc" | "gp"
|
||||
): Promise<{ success: boolean; listing?: Listing; error?: string; previousBidderId?: number }> {
|
||||
// Usar transação para garantir atomicidade
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Buscar listing atual
|
||||
const listing = await tx.listing.findUnique({
|
||||
where: { listing_id: listingId },
|
||||
});
|
||||
|
||||
if (!listing) {
|
||||
return { success: false, error: "Anúncio não encontrado" };
|
||||
}
|
||||
|
||||
if (!listing.is_auction) {
|
||||
return { success: false, error: "Este anúncio não é um leilão" };
|
||||
}
|
||||
|
||||
if (listing.listing_status !== "active") {
|
||||
return { success: false, error: "Leilão não está ativo" };
|
||||
}
|
||||
|
||||
if (listing.seller_id === bidderId) {
|
||||
return { success: false, error: "Você não pode dar lance no seu próprio anúncio" };
|
||||
}
|
||||
|
||||
// Validar valor mÃnimo
|
||||
const minIncrement = listing.min_bid_increment ? Number(listing.min_bid_increment) : 10;
|
||||
const currentBid = listing.current_bid_amount ? Number(listing.current_bid_amount) : 0;
|
||||
const minBid = currentBid + minIncrement;
|
||||
|
||||
if (amount < minBid) {
|
||||
return { success: false, error: `O lance deve ser de pelo menos ${minBid.toFixed(2)}` };
|
||||
}
|
||||
|
||||
const previousBidderId = listing.current_bidder_id;
|
||||
|
||||
// Atualizar listing
|
||||
const updatedListing = await tx.listing.update({
|
||||
where: { listing_id: listingId },
|
||||
data: {
|
||||
current_bid_amount: amount,
|
||||
current_bidder_id: bidderId,
|
||||
},
|
||||
});
|
||||
|
||||
// Buscar character name
|
||||
const character = await tx.userCharacter.findFirst({
|
||||
where: {
|
||||
user_id: updatedListing.seller_id,
|
||||
server_name: updatedListing.server_name,
|
||||
is_main: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Converter todos os campos Decimal e BigInt para números JavaScript
|
||||
return {
|
||||
success: true,
|
||||
listing: {
|
||||
...updatedListing,
|
||||
price_pix: updatedListing.price_pix ? Number(updatedListing.price_pix) : null,
|
||||
current_bid_amount: updatedListing.current_bid_amount ? Number(updatedListing.current_bid_amount) : null,
|
||||
min_bid_increment: updatedListing.min_bid_increment ? Number(updatedListing.min_bid_increment) : null,
|
||||
initial_price_pix: updatedListing.initial_price_pix ? Number(updatedListing.initial_price_pix) : null,
|
||||
price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null,
|
||||
initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null,
|
||||
seller_character_name: character?.character_name || null,
|
||||
} as Listing,
|
||||
previousBidderId: previousBidderId || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Queries de notificações usando Prisma
|
||||
* Migrado de lib/db/queries/notifications.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
export type NotificationType = "bid" | "sale" | "purchase" | "escrow" | "dispute" | "system";
|
||||
|
||||
export interface Notification {
|
||||
notification_id: number;
|
||||
user_id: number;
|
||||
notification_type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
related_id: number | null;
|
||||
is_read: boolean;
|
||||
send_whatsapp: boolean;
|
||||
whatsapp_sent: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateNotificationData {
|
||||
user_id: number;
|
||||
notification_type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
related_id?: number | null;
|
||||
send_whatsapp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma nova notificação
|
||||
*/
|
||||
export async function createNotification(
|
||||
data: CreateNotificationData
|
||||
): Promise<Notification> {
|
||||
return await prisma.notification.create({
|
||||
data: {
|
||||
user_id: data.user_id,
|
||||
notification_type: data.notification_type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
related_id: data.related_id || null,
|
||||
send_whatsapp: data.send_whatsapp || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca notificações de um usuário
|
||||
*/
|
||||
export async function getUserNotifications(
|
||||
userId: number,
|
||||
filters: { is_read?: boolean; type?: NotificationType; limit?: number } = {}
|
||||
): Promise<Notification[]> {
|
||||
const { is_read, type, limit = 50 } = filters;
|
||||
|
||||
const where: any = { user_id: userId };
|
||||
|
||||
if (is_read !== undefined) {
|
||||
where.is_read = is_read;
|
||||
}
|
||||
|
||||
if (type) {
|
||||
where.notification_type = type;
|
||||
}
|
||||
|
||||
return await prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta notificações não lidas de um usuário
|
||||
*/
|
||||
export async function getUnreadCount(userId: number): Promise<number> {
|
||||
return await prisma.notification.count({
|
||||
where: {
|
||||
user_id: userId,
|
||||
is_read: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca notificação como lida
|
||||
*/
|
||||
export async function markAsRead(notificationId: number, userId: number): Promise<boolean> {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: {
|
||||
notification_id: notificationId,
|
||||
user_id: userId,
|
||||
},
|
||||
data: {
|
||||
is_read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca todas as notificações de um usuário como lidas
|
||||
*/
|
||||
export async function markAllAsRead(userId: number): Promise<number> {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
is_read: false,
|
||||
},
|
||||
data: {
|
||||
is_read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove notificação
|
||||
*/
|
||||
export async function deleteNotification(notificationId: number, userId: number): Promise<boolean> {
|
||||
try {
|
||||
await prisma.notification.delete({
|
||||
where: {
|
||||
notification_id: notificationId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Queries de Purchase Intentions usando Prisma
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
export type PurchaseIntentionStatus = "pending" | "accepted" | "rejected" | "expired" | "completed";
|
||||
export type PaymentMethod = "pix" | "tibia_coins" | "gold_coins" | "trade";
|
||||
|
||||
export interface CreatePurchaseIntentionData {
|
||||
listing_id: number;
|
||||
buyer_id: number;
|
||||
payment_method: PaymentMethod;
|
||||
buyer_message?: string;
|
||||
expires_at?: Date;
|
||||
}
|
||||
|
||||
export interface PurchaseIntention {
|
||||
intention_id: number;
|
||||
listing_id: number;
|
||||
buyer_id: number;
|
||||
payment_method: string;
|
||||
status: PurchaseIntentionStatus;
|
||||
buyer_message: string | null;
|
||||
seller_response: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
expires_at: Date | null;
|
||||
accepted_at: Date | null;
|
||||
rejected_at: Date | null;
|
||||
// Relacionamentos
|
||||
listing?: {
|
||||
listing_id: number;
|
||||
item_name: string;
|
||||
seller_id: number;
|
||||
};
|
||||
buyer?: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma nova intenção de compra
|
||||
*/
|
||||
export async function createPurchaseIntention(
|
||||
data: CreatePurchaseIntentionData
|
||||
): Promise<PurchaseIntention> {
|
||||
return await prisma.purchaseIntention.create({
|
||||
data: {
|
||||
listing_id: data.listing_id,
|
||||
buyer_id: data.buyer_id,
|
||||
payment_method: data.payment_method,
|
||||
buyer_message: data.buyer_message || null,
|
||||
expires_at: data.expires_at || null,
|
||||
status: "pending",
|
||||
},
|
||||
include: {
|
||||
listing: {
|
||||
select: {
|
||||
listing_id: true,
|
||||
item_name: true,
|
||||
seller_id: true,
|
||||
},
|
||||
},
|
||||
buyer: {
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as PurchaseIntention;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca intenções de um anúncio específico
|
||||
*/
|
||||
export async function getPurchaseIntentionsByListing(
|
||||
listingId: number,
|
||||
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
|
||||
): Promise<PurchaseIntention[]> {
|
||||
const { status, limit = 50 } = filters;
|
||||
|
||||
const where: any = { listing_id: listingId };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
return await prisma.purchaseIntention.findMany({
|
||||
where,
|
||||
include: {
|
||||
listing: {
|
||||
select: {
|
||||
listing_id: true,
|
||||
item_name: true,
|
||||
seller_id: true,
|
||||
},
|
||||
},
|
||||
buyer: {
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
}) as PurchaseIntention[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca intenções de um comprador
|
||||
*/
|
||||
export async function getPurchaseIntentionsByBuyer(
|
||||
buyerId: number,
|
||||
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
|
||||
): Promise<PurchaseIntention[]> {
|
||||
const { status, limit = 50 } = filters;
|
||||
|
||||
const where: any = { buyer_id: buyerId };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
return await prisma.purchaseIntention.findMany({
|
||||
where,
|
||||
include: {
|
||||
listing: {
|
||||
select: {
|
||||
listing_id: true,
|
||||
item_name: true,
|
||||
seller_id: true,
|
||||
},
|
||||
},
|
||||
buyer: {
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
}) as PurchaseIntention[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca uma intenção específica por ID
|
||||
*/
|
||||
export async function getPurchaseIntentionById(
|
||||
intentionId: number
|
||||
): Promise<PurchaseIntention | null> {
|
||||
return await prisma.purchaseIntention.findUnique({
|
||||
where: { intention_id: intentionId },
|
||||
include: {
|
||||
listing: {
|
||||
select: {
|
||||
listing_id: true,
|
||||
item_name: true,
|
||||
seller_id: true,
|
||||
},
|
||||
},
|
||||
buyer: {
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as PurchaseIntention | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza o status de uma intenção
|
||||
*/
|
||||
export async function updatePurchaseIntentionStatus(
|
||||
intentionId: number,
|
||||
status: PurchaseIntentionStatus,
|
||||
sellerResponse?: string
|
||||
): Promise<PurchaseIntention> {
|
||||
const updateData: any = {
|
||||
status,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (status === "accepted") {
|
||||
updateData.accepted_at = new Date();
|
||||
updateData.seller_response = sellerResponse || null;
|
||||
} else if (status === "rejected") {
|
||||
updateData.rejected_at = new Date();
|
||||
updateData.seller_response = sellerResponse || null;
|
||||
} else if (sellerResponse) {
|
||||
updateData.seller_response = sellerResponse;
|
||||
}
|
||||
|
||||
return await prisma.purchaseIntention.update({
|
||||
where: { intention_id: intentionId },
|
||||
data: updateData,
|
||||
include: {
|
||||
listing: {
|
||||
select: {
|
||||
listing_id: true,
|
||||
item_name: true,
|
||||
seller_id: true,
|
||||
},
|
||||
},
|
||||
buyer: {
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as PurchaseIntention;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta intenções pendentes de um anúncio
|
||||
*/
|
||||
export async function getPendingIntentionsCount(listingId: number): Promise<number> {
|
||||
return await prisma.purchaseIntention.count({
|
||||
where: {
|
||||
listing_id: listingId,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se já existe uma intenção pendente para um listing+buyer
|
||||
*/
|
||||
export async function hasPendingIntention(
|
||||
listingId: number,
|
||||
buyerId: number
|
||||
): Promise<boolean> {
|
||||
const count = await prisma.purchaseIntention.count({
|
||||
where: {
|
||||
listing_id: listingId,
|
||||
buyer_id: buyerId,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Queries de servidores usando Prisma
|
||||
* Migrado de lib/db/queries/servers.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
|
||||
export interface Server {
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null;
|
||||
location: "NA" | "SA" | "EU" | "OC" | "BR" | null;
|
||||
is_active: boolean;
|
||||
total_listings: number;
|
||||
total_users: number;
|
||||
total_subscribers: number;
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
average_listing_price: number;
|
||||
last_activity_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
total_listings?: number;
|
||||
total_users?: number;
|
||||
total_subscribers?: number;
|
||||
total_transactions?: number;
|
||||
total_volume?: number;
|
||||
average_listing_price?: number;
|
||||
last_activity_at?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os servidores
|
||||
*/
|
||||
export async function getAllServers(): Promise<Server[]> {
|
||||
return await prisma.server.findMany({
|
||||
orderBy: { server_name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista apenas servidores ativos
|
||||
*/
|
||||
export async function getActiveServers(): Promise<Server[]> {
|
||||
return await prisma.server.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: { server_name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca servidor por nome
|
||||
*/
|
||||
export async function getServerByName(serverName: string): Promise<Server | null> {
|
||||
return await prisma.server.findUnique({
|
||||
where: { server_name: serverName },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca servidor por ID
|
||||
*/
|
||||
export async function getServerById(serverId: number): Promise<Server | null> {
|
||||
return await prisma.server.findUnique({
|
||||
where: { server_id: serverId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se servidor existe e está ativo
|
||||
*/
|
||||
export async function isServerActive(serverName: string): Promise<boolean> {
|
||||
const count = await prisma.server.count({
|
||||
where: {
|
||||
server_name: serverName,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa estatísticas do servidor
|
||||
*/
|
||||
export async function incrementServerStats(
|
||||
serverName: string,
|
||||
stats: Partial<{
|
||||
total_listings: number;
|
||||
total_users: number;
|
||||
total_subscribers: number;
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
const updateData: any = {
|
||||
last_activity_at: new Date(),
|
||||
};
|
||||
|
||||
if (stats.total_listings !== undefined) {
|
||||
updateData.total_listings = { increment: stats.total_listings };
|
||||
}
|
||||
if (stats.total_users !== undefined) {
|
||||
updateData.total_users = { increment: stats.total_users };
|
||||
}
|
||||
if (stats.total_subscribers !== undefined) {
|
||||
updateData.total_subscribers = { increment: stats.total_subscribers };
|
||||
}
|
||||
if (stats.total_transactions !== undefined) {
|
||||
updateData.total_transactions = { increment: stats.total_transactions };
|
||||
}
|
||||
if (stats.total_volume !== undefined) {
|
||||
updateData.total_volume = { increment: stats.total_volume };
|
||||
}
|
||||
|
||||
await prisma.server.update({
|
||||
where: { server_name: serverName },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza estatísticas completas do servidor
|
||||
*/
|
||||
export async function updateServerStats(
|
||||
serverName: string,
|
||||
stats: ServerStats
|
||||
): Promise<void> {
|
||||
const updateData: any = {};
|
||||
|
||||
if (stats.total_listings !== undefined) updateData.total_listings = stats.total_listings;
|
||||
if (stats.total_users !== undefined) updateData.total_users = stats.total_users;
|
||||
if (stats.total_subscribers !== undefined) updateData.total_subscribers = stats.total_subscribers;
|
||||
if (stats.total_transactions !== undefined) updateData.total_transactions = stats.total_transactions;
|
||||
if (stats.total_volume !== undefined) updateData.total_volume = stats.total_volume;
|
||||
if (stats.average_listing_price !== undefined) updateData.average_listing_price = stats.average_listing_price;
|
||||
if (stats.last_activity_at !== undefined) updateData.last_activity_at = stats.last_activity_at;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.server.update({
|
||||
where: { server_name: serverName },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa total_users de múltiplos servidores
|
||||
*/
|
||||
export async function incrementUsersForServers(serverNames: string[]): Promise<void> {
|
||||
if (serverNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.server.updateMany({
|
||||
where: {
|
||||
server_name: { in: serverNames },
|
||||
},
|
||||
data: {
|
||||
total_users: { increment: 1 },
|
||||
last_activity_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Queries de Telemetria usando Prisma
|
||||
* Migrado de lib/db/queries/telemetry.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export type TelemetryEventType =
|
||||
| "ad_view"
|
||||
| "ad_click"
|
||||
| "ad_exit"
|
||||
| "search"
|
||||
| "filter"
|
||||
| "favorite"
|
||||
| "unfavorite"
|
||||
| "purchase"
|
||||
| "purchase_intention_created"
|
||||
| "purchase_intention_viewed"
|
||||
| "bid_placed"
|
||||
| "bid_viewed"
|
||||
| "seller_profile_viewed"
|
||||
| "inquiry"
|
||||
| "page_view"
|
||||
| "price_change";
|
||||
|
||||
export interface TelemetryEvent {
|
||||
event_id: number;
|
||||
event_type: TelemetryEventType;
|
||||
ad_id: number | null;
|
||||
user_id: number | null;
|
||||
session_id: string | null;
|
||||
metadata: Record<string, any>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TrackEventData {
|
||||
event_type: TelemetryEventType;
|
||||
ad_id?: number;
|
||||
user_id?: number;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AdAnalytics {
|
||||
ad_id: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
ctr: number;
|
||||
avg_view_duration: number;
|
||||
bounce_rate: number;
|
||||
inquiries: number;
|
||||
favorites: number;
|
||||
conversion_rate: number;
|
||||
time_to_sell: number | null;
|
||||
last_updated: Date;
|
||||
}
|
||||
|
||||
export interface UserBehavior {
|
||||
user_id: number;
|
||||
search_queries: string[];
|
||||
filters_applied: Record<string, any>;
|
||||
items_viewed: Array<{
|
||||
item_id: number;
|
||||
duration: number;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
items_favorited: number[];
|
||||
purchases: Array<{
|
||||
ad_id: number;
|
||||
price: number;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
active_hours: Array<{
|
||||
hour: number;
|
||||
activity_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MarketplaceStats {
|
||||
total_transactions: number;
|
||||
avg_price_by_category: Record<string, number>;
|
||||
conversion_rate: number;
|
||||
most_searched_items: Array<{
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
search_count: number;
|
||||
}>;
|
||||
most_sold_items: Array<{
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
sales_count: number;
|
||||
}>;
|
||||
peak_hours: Array<{
|
||||
hour: number;
|
||||
activity_count: number;
|
||||
}>;
|
||||
avg_time_to_sell: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra um evento de telemetria
|
||||
*/
|
||||
export async function trackEvent(
|
||||
data: TrackEventData
|
||||
): Promise<TelemetryEvent> {
|
||||
return await prisma.telemetryEvent.create({
|
||||
data: {
|
||||
event_type: data.event_type,
|
||||
ad_id: data.ad_id || null,
|
||||
user_id: data.user_id || null,
|
||||
session_id: data.session_id || null,
|
||||
metadata: data.metadata || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra múltiplos eventos em batch
|
||||
*/
|
||||
export async function trackEventsBatch(
|
||||
events: TrackEventData[]
|
||||
): Promise<TelemetryEvent[]> {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
return await Promise.all(
|
||||
events.map((event) =>
|
||||
tx.telemetryEvent.create({
|
||||
data: {
|
||||
event_type: event.event_type,
|
||||
ad_id: event.ad_id || null,
|
||||
user_id: event.user_id || null,
|
||||
session_id: event.session_id || null,
|
||||
metadata: event.metadata || {},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca analytics de um anúncio específico
|
||||
*/
|
||||
export async function getAdAnalytics(adId: number): Promise<AdAnalytics | null> {
|
||||
return await prisma.adAnalytics.findUnique({
|
||||
where: { ad_id: adId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca comportamento do usuário (comprador)
|
||||
*/
|
||||
export async function getUserBehavior(
|
||||
userId: number,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<UserBehavior | null> {
|
||||
// Verificar se usuário existe
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { user_id: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateFilter: Prisma.TelemetryEventWhereInput = {};
|
||||
if (startDate && endDate) {
|
||||
dateFilter.timestamp = {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar queries de busca
|
||||
const searchEvents = await prisma.telemetryEvent.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
event_type: "search",
|
||||
...dateFilter,
|
||||
},
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Buscar filtros aplicados
|
||||
const filterEvents = await prisma.telemetryEvent.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
event_type: "filter",
|
||||
...dateFilter,
|
||||
},
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Buscar itens visualizados
|
||||
const viewEvents = await prisma.telemetryEvent.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
event_type: "ad_view",
|
||||
ad_id: { not: null },
|
||||
...dateFilter,
|
||||
},
|
||||
select: {
|
||||
ad_id: true,
|
||||
metadata: true,
|
||||
timestamp: true,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
|
||||
// Buscar favoritos
|
||||
const favoriteEvents = await prisma.telemetryEvent.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
event_type: "favorite",
|
||||
ad_id: { not: null },
|
||||
...dateFilter,
|
||||
},
|
||||
select: {
|
||||
ad_id: true,
|
||||
},
|
||||
distinct: ["ad_id"],
|
||||
});
|
||||
|
||||
// Buscar compras
|
||||
const purchaseEvents = await prisma.telemetryEvent.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
event_type: "purchase",
|
||||
ad_id: { not: null },
|
||||
...dateFilter,
|
||||
},
|
||||
select: {
|
||||
ad_id: true,
|
||||
metadata: true,
|
||||
timestamp: true,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
|
||||
// Buscar horas ativas
|
||||
const activeHoursRaw = await prisma.$queryRaw<
|
||||
Array<{ hour: number; count: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
|
||||
COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE user_id = ${userId}
|
||||
${startDate && endDate ? Prisma.sql`AND timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`;
|
||||
|
||||
// Buscar item_id dos anúncios visualizados
|
||||
const itemIds = await prisma.$queryRaw<Array<{ item_id: number }>>`
|
||||
SELECT DISTINCT l.item_id
|
||||
FROM telemetry_events te
|
||||
JOIN listings l ON l.listing_id = te.ad_id
|
||||
WHERE te.user_id = ${userId}
|
||||
AND te.event_type = 'ad_view'
|
||||
AND te.ad_id IS NOT NULL
|
||||
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
|
||||
`;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
search_queries: searchEvents
|
||||
.map((r) => (r.metadata as any)?.query)
|
||||
.filter((q): q is string => !!q),
|
||||
filters_applied: filterEvents.reduce(
|
||||
(acc, r) => ({ ...acc, ...(r.metadata as any) }),
|
||||
{} as Record<string, any>
|
||||
),
|
||||
items_viewed: viewEvents.map((r) => ({
|
||||
item_id: r.ad_id!,
|
||||
duration: ((r.metadata as any)?.duration || 0) as number,
|
||||
timestamp: r.timestamp,
|
||||
})),
|
||||
items_favorited: favoriteEvents.map((r) => r.ad_id!),
|
||||
purchases: purchaseEvents.map((r) => ({
|
||||
ad_id: r.ad_id!,
|
||||
price: ((r.metadata as any)?.price || 0) as number,
|
||||
timestamp: r.timestamp,
|
||||
})),
|
||||
active_hours: activeHoursRaw.map((r) => ({
|
||||
hour: r.hour,
|
||||
activity_count: Number(r.count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca métricas agregadas do marketplace
|
||||
*/
|
||||
export async function getMarketplaceStats(
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<MarketplaceStats> {
|
||||
const dateFilter = startDate && endDate
|
||||
? Prisma.sql`WHERE timestamp BETWEEN ${startDate} AND ${endDate}`
|
||||
: Prisma.empty;
|
||||
|
||||
// Total de transações
|
||||
const transactionsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::TEXT as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
${dateFilter}
|
||||
`;
|
||||
const total_transactions = Number(transactionsResult[0]?.count || 0);
|
||||
|
||||
// Preço médio por categoria
|
||||
const avgPriceResult = await prisma.$queryRaw<
|
||||
Array<{ item_name: string; avg_price: string }>
|
||||
>`
|
||||
SELECT
|
||||
l.item_name,
|
||||
AVG(COALESCE(l.price_pix, l.price_tibia_coins, l.price_gold_coins))::TEXT as avg_price
|
||||
FROM listings l
|
||||
JOIN telemetry_events te ON te.ad_id = l.listing_id
|
||||
WHERE te.event_type = 'purchase'
|
||||
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
|
||||
GROUP BY l.item_name
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
// Taxa de conversão
|
||||
const viewsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_view'
|
||||
${dateFilter}
|
||||
`;
|
||||
const purchasesResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
${dateFilter}
|
||||
`;
|
||||
|
||||
const viewsCount = Number(viewsResult[0]?.count || 0);
|
||||
const purchasesCount = Number(purchasesResult[0]?.count || 0);
|
||||
const conversion_rate = viewsCount > 0 ? (purchasesCount / viewsCount) * 100 : 0;
|
||||
|
||||
// Itens mais procurados
|
||||
const mostSearchedResult = await prisma.$queryRaw<
|
||||
Array<{ item_name: string; search_count: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
(metadata->>'query') as item_name,
|
||||
COUNT(*) as search_count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'search' AND (metadata->>'query') IS NOT NULL
|
||||
${dateFilter}
|
||||
GROUP BY (metadata->>'query')
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
// Itens mais vendidos
|
||||
const mostSoldResult = await prisma.$queryRaw<
|
||||
Array<{ item_id: number; item_name: string; sales_count: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
l.item_id,
|
||||
l.item_name,
|
||||
COUNT(*) as sales_count
|
||||
FROM telemetry_events te
|
||||
JOIN listings l ON l.listing_id = te.ad_id
|
||||
WHERE te.event_type = 'purchase'
|
||||
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
|
||||
GROUP BY l.item_id, l.item_name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
// Horários de pico
|
||||
const peakHoursResult = await prisma.$queryRaw<
|
||||
Array<{ hour: number; activity_count: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
|
||||
COUNT(*) as activity_count
|
||||
FROM telemetry_events
|
||||
${dateFilter}
|
||||
GROUP BY hour
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
// Tempo médio para venda
|
||||
const avgTimeResult = await prisma.$queryRaw<
|
||||
Array<{ avg_hours: string }>
|
||||
>`
|
||||
SELECT AVG(aa.time_to_sell)::TEXT as avg_hours
|
||||
FROM ad_analytics aa
|
||||
WHERE aa.time_to_sell IS NOT NULL
|
||||
`;
|
||||
|
||||
return {
|
||||
total_transactions,
|
||||
avg_price_by_category: avgPriceResult.reduce(
|
||||
(acc, r) => {
|
||||
acc[r.item_name] = parseFloat(r.avg_price);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
conversion_rate,
|
||||
most_searched_items: mostSearchedResult.map((r) => ({
|
||||
item_id: 0,
|
||||
item_name: r.item_name || "",
|
||||
search_count: Number(r.search_count),
|
||||
})),
|
||||
most_sold_items: mostSoldResult.map((r) => ({
|
||||
item_id: r.item_id,
|
||||
item_name: r.item_name,
|
||||
sales_count: Number(r.sales_count),
|
||||
})),
|
||||
peak_hours: peakHoursResult.map((r) => ({
|
||||
hour: r.hour,
|
||||
activity_count: Number(r.activity_count),
|
||||
})),
|
||||
avg_time_to_sell: parseFloat(avgTimeResult[0]?.avg_hours || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega eventos de telemetria em métricas de analytics
|
||||
* Executar periodicamente (ex: a cada hora)
|
||||
*/
|
||||
export async function aggregateHourlyMetrics(): Promise<void> {
|
||||
// Usar $queryRaw para queries complexas de agregação
|
||||
// Por enquanto, manter a lógica original mas adaptada para Prisma
|
||||
|
||||
// Agregar impressões
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO ad_analytics (ad_id, impressions, last_updated)
|
||||
SELECT
|
||||
ad_id,
|
||||
COUNT(*) as impressions,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_view'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
ON CONFLICT (ad_id) DO UPDATE SET
|
||||
impressions = ad_analytics.impressions + EXCLUDED.impressions,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
// Agregar cliques
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET clicks = clicks + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_click'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`;
|
||||
|
||||
// Atualizar CTR
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics
|
||||
SET ctr = CASE
|
||||
WHEN impressions > 0 THEN (clicks::DECIMAL / impressions::DECIMAL) * 100
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE impressions > 0
|
||||
`;
|
||||
|
||||
// Agregar duração média de visualização
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
avg_view_duration = COALESCE(
|
||||
(
|
||||
SELECT AVG((metadata->>'duration')::DECIMAL)
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
AND metadata->>'duration' IS NOT NULL
|
||||
),
|
||||
avg_view_duration
|
||||
),
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
`;
|
||||
|
||||
// Calcular bounce rate
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
bounce_rate = CASE
|
||||
WHEN aa.impressions > 0 THEN
|
||||
(
|
||||
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'ad_exit'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
AND (metadata->>'duration')::DECIMAL < 3
|
||||
)
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE aa.impressions > 0
|
||||
`;
|
||||
|
||||
// Agregar inquiries
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET inquiries = inquiries + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'inquiry'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`;
|
||||
|
||||
// Agregar favoritos
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET favorites = favorites + sub.count,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT ad_id, COUNT(*) as count
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'favorite'
|
||||
AND ad_id IS NOT NULL
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
GROUP BY ad_id
|
||||
) sub
|
||||
WHERE aa.ad_id = sub.ad_id
|
||||
`;
|
||||
|
||||
// Calcular taxa de conversão
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
conversion_rate = CASE
|
||||
WHEN aa.impressions > 0 THEN
|
||||
(
|
||||
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
|
||||
FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
ELSE 0
|
||||
END,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM telemetry_events
|
||||
WHERE event_type = 'purchase'
|
||||
AND ad_id = aa.ad_id
|
||||
AND timestamp >= NOW() - INTERVAL '1 hour'
|
||||
)
|
||||
`;
|
||||
|
||||
// Calcular time_to_sell
|
||||
await prisma.$executeRaw`
|
||||
UPDATE ad_analytics aa
|
||||
SET
|
||||
time_to_sell = EXTRACT(EPOCH FROM (l.sold_at - l.created_at)) / 3600,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
FROM listings l
|
||||
WHERE aa.ad_id = l.listing_id
|
||||
AND l.listing_status = 'sold'
|
||||
AND l.sold_at IS NOT NULL
|
||||
AND aa.time_to_sell IS NULL
|
||||
AND l.sold_at >= NOW() - INTERVAL '1 hour'
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Queries de transactions usando Prisma
|
||||
* Migrado de lib/db/queries/transactions.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export type TransactionStatus =
|
||||
| "pending"
|
||||
| "deposited"
|
||||
| "item_delivered"
|
||||
| "completed"
|
||||
| "disputed"
|
||||
| "cancelled"
|
||||
| "refunded";
|
||||
|
||||
export interface Transaction {
|
||||
transaction_id: number;
|
||||
listing_id: number | null;
|
||||
buyer_id: number;
|
||||
seller_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
amount: number;
|
||||
payment_method: "pix" | "tibia_coins" | "gold_coins" | null;
|
||||
escrow_fee: number | null;
|
||||
transaction_status: TransactionStatus;
|
||||
created_at: Date;
|
||||
deposited_at: Date | null;
|
||||
delivered_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
buyer_confirmed: boolean;
|
||||
seller_confirmed: boolean;
|
||||
buyer_notes: string | null;
|
||||
seller_notes: string | null;
|
||||
}
|
||||
|
||||
export interface CreateTransactionData {
|
||||
listing_id?: number;
|
||||
buyer_id: number;
|
||||
seller_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
amount: number;
|
||||
payment_method: "pix" | "tibia_coins" | "gold_coins";
|
||||
escrow_fee?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTransactionStatusData {
|
||||
transaction_status: TransactionStatus;
|
||||
deposited_at?: Date;
|
||||
delivered_at?: Date;
|
||||
completed_at?: Date;
|
||||
buyer_confirmed?: boolean;
|
||||
seller_confirmed?: boolean;
|
||||
buyer_notes?: string;
|
||||
seller_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma nova transação
|
||||
*/
|
||||
export async function createTransaction(
|
||||
data: CreateTransactionData
|
||||
): Promise<Transaction> {
|
||||
// Calcular escrow_fee se não fornecido (2% do valor)
|
||||
const escrowFee = data.escrow_fee ?? Number((data.amount * 0.02).toFixed(2));
|
||||
|
||||
return await prisma.transaction.create({
|
||||
data: {
|
||||
listing_id: data.listing_id || null,
|
||||
buyer_id: data.buyer_id,
|
||||
seller_id: data.seller_id,
|
||||
item_name: data.item_name,
|
||||
quantity: data.quantity,
|
||||
server_name: data.server_name,
|
||||
amount: data.amount,
|
||||
payment_method: data.payment_method,
|
||||
escrow_fee: escrowFee,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca transação por ID
|
||||
*/
|
||||
export async function getTransactionById(
|
||||
transactionId: number
|
||||
): Promise<Transaction | null> {
|
||||
return await prisma.transaction.findUnique({
|
||||
where: { transaction_id: transactionId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza status da transação
|
||||
*/
|
||||
export async function updateTransactionStatus(
|
||||
transactionId: number,
|
||||
data: UpdateTransactionStatusData
|
||||
): Promise<Transaction | null> {
|
||||
const updateData: Prisma.TransactionUpdateInput = {
|
||||
transaction_status: data.transaction_status,
|
||||
};
|
||||
|
||||
if (data.deposited_at !== undefined) updateData.deposited_at = data.deposited_at;
|
||||
if (data.delivered_at !== undefined) updateData.delivered_at = data.delivered_at;
|
||||
if (data.completed_at !== undefined) updateData.completed_at = data.completed_at;
|
||||
if (data.buyer_confirmed !== undefined) updateData.buyer_confirmed = data.buyer_confirmed;
|
||||
if (data.seller_confirmed !== undefined) updateData.seller_confirmed = data.seller_confirmed;
|
||||
if (data.buyer_notes !== undefined) updateData.buyer_notes = data.buyer_notes;
|
||||
if (data.seller_notes !== undefined) updateData.seller_notes = data.seller_notes;
|
||||
|
||||
return await prisma.transaction.update({
|
||||
where: { transaction_id: transactionId },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca transações de um usuário (comprador ou vendedor)
|
||||
*/
|
||||
export async function getTransactionsByUser(
|
||||
userId: number,
|
||||
filters: {
|
||||
role?: "buyer" | "seller" | "both";
|
||||
status?: TransactionStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}
|
||||
): Promise<{ transactions: Transaction[]; total: number }> {
|
||||
const { role = "both", status, page = 1, pageSize = 20 } = filters;
|
||||
|
||||
const where: Prisma.TransactionWhereInput = {};
|
||||
|
||||
if (role === "buyer") {
|
||||
where.buyer_id = userId;
|
||||
} else if (role === "seller") {
|
||||
where.seller_id = userId;
|
||||
} else {
|
||||
where.OR = [
|
||||
{ buyer_id: userId },
|
||||
{ seller_id: userId },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.transaction_status = status;
|
||||
}
|
||||
|
||||
// Contar total
|
||||
const total = await prisma.transaction.count({ where });
|
||||
|
||||
// Buscar transações
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
transactions,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Queries de usuários usando Prisma
|
||||
* Migrado de lib/db/queries/users.ts
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
export interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
cpf?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
whatsapp_notifications?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo usuário
|
||||
*/
|
||||
export async function createUser(
|
||||
data: CreateUserData
|
||||
): Promise<{ userId: number; username: string; email: string }> {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
password_hash: data.password_hash,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
phone: data.phone || null,
|
||||
cpf: data.cpf || null,
|
||||
},
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por email (inclui password_hash para login)
|
||||
*/
|
||||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por username
|
||||
*/
|
||||
export async function getUserByUsername(
|
||||
username: string
|
||||
): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por ID (sem password_hash)
|
||||
*/
|
||||
export async function getUserById(userId: number): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
phone: true,
|
||||
cpf: true,
|
||||
account_level: true,
|
||||
has_donated: true,
|
||||
whatsapp_notifications: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
last_login: true,
|
||||
is_active: true,
|
||||
is_verified: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por CPF (verifica duplicidade)
|
||||
*/
|
||||
export async function getUserByCpf(cpf: string): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
where: { cpf },
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza dados do usuário
|
||||
*/
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
data: UpdateUserData
|
||||
): Promise<User> {
|
||||
const updateData: any = {};
|
||||
if (data.first_name !== undefined) updateData.first_name = data.first_name;
|
||||
if (data.last_name !== undefined) updateData.last_name = data.last_name;
|
||||
if (data.phone !== undefined) updateData.phone = data.phone || null;
|
||||
if (data.whatsapp_notifications !== undefined)
|
||||
updateData.whatsapp_notifications = data.whatsapp_notifications;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { user_id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
phone: true,
|
||||
cpf: true,
|
||||
account_level: true,
|
||||
has_donated: true,
|
||||
whatsapp_notifications: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
last_login: true,
|
||||
is_active: true,
|
||||
is_verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza timestamp de último login
|
||||
*/
|
||||
export async function updateLastLogin(userId: number): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { user_id: userId },
|
||||
data: { last_login: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minuto - dados considerados frescos
|
||||
refetchOnWindowFocus: true, // Refetch quando a janela recebe foco
|
||||
refetchOnReconnect: true, // Refetch quando reconecta
|
||||
retry: 1, // Tentar apenas 1 vez em caso de erro
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Classe para tracking de telemetria no marketplace
|
||||
* Gerencia eventos client-side com debouncing e batch sending
|
||||
*/
|
||||
|
||||
type TelemetryEventType =
|
||||
| "ad_view"
|
||||
| "ad_click"
|
||||
| "ad_exit"
|
||||
| "search"
|
||||
| "filter"
|
||||
| "favorite"
|
||||
| "unfavorite"
|
||||
| "purchase"
|
||||
| "purchase_intention_created"
|
||||
| "purchase_intention_viewed"
|
||||
| "bid_placed"
|
||||
| "bid_viewed"
|
||||
| "seller_profile_viewed"
|
||||
| "inquiry"
|
||||
| "page_view"
|
||||
| "price_change";
|
||||
|
||||
interface TelemetryEvent {
|
||||
event_type: TelemetryEventType;
|
||||
ad_id?: number | null;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PendingEvent extends TelemetryEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class MarketTelemetry {
|
||||
private sessionId: string;
|
||||
private userId?: number;
|
||||
private eventQueue: PendingEvent[] = [];
|
||||
private viewStartTimes: Map<number, number> = new Map();
|
||||
private batchInterval: NodeJS.Timeout | null = null;
|
||||
private readonly BATCH_INTERVAL_MS = 30000; // 30 segundos
|
||||
private readonly MAX_BATCH_SIZE = 100;
|
||||
|
||||
constructor(userId?: number) {
|
||||
this.userId = userId;
|
||||
this.sessionId = this.generateSessionId();
|
||||
this.startBatchInterval();
|
||||
this.setupPageUnloadHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera um ID de sessão único
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
|
||||
// Tentar recuperar da sessionStorage
|
||||
let sessionId = sessionStorage.getItem("telemetry_session_id");
|
||||
if (sessionId) return sessionId;
|
||||
|
||||
// Gerar novo ID
|
||||
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
sessionStorage.setItem("telemetry_session_id", sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia tracking de visualização de anúncio
|
||||
*/
|
||||
trackAdView(adId: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const startTime = Date.now();
|
||||
this.viewStartTimes.set(adId, startTime);
|
||||
|
||||
this.queueEvent({
|
||||
event_type: "ad_view",
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
timestamp: startTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finaliza tracking de visualização com duração
|
||||
*/
|
||||
trackAdExit(adId: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const startTime = this.viewStartTimes.get(adId);
|
||||
if (!startTime) return;
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000; // em segundos
|
||||
this.viewStartTimes.delete(adId);
|
||||
|
||||
this.queueEvent({
|
||||
event_type: "ad_exit",
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
duration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra clique em anúncio
|
||||
*/
|
||||
trackAdClick(adId: number, position?: number): void {
|
||||
this.queueEvent({
|
||||
event_type: "ad_click",
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
position,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra busca
|
||||
*/
|
||||
trackSearch(query: string, filters?: Record<string, any>): void {
|
||||
this.queueEvent({
|
||||
event_type: "search",
|
||||
metadata: {
|
||||
query,
|
||||
filters,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra aplicação de filtros
|
||||
*/
|
||||
trackFilterApplied(filters: Record<string, any>): void {
|
||||
this.queueEvent({
|
||||
event_type: "filter",
|
||||
metadata: {
|
||||
filters,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra favoritar/desfavoritar
|
||||
*/
|
||||
trackFavorite(adId: number, action: "favorite" | "unfavorite"): void {
|
||||
this.queueEvent({
|
||||
event_type: action,
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra compra
|
||||
*/
|
||||
trackPurchase(adId: number, price: number): void {
|
||||
this.queueEvent({
|
||||
event_type: "purchase",
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
price,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
// Enviar imediatamente para eventos críticos
|
||||
this.flushQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra inquiry (mensagem ao vendedor)
|
||||
*/
|
||||
trackInquiry(adId: number): void {
|
||||
this.queueEvent({
|
||||
event_type: "inquiry",
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra visualização de página
|
||||
*/
|
||||
trackPageView(page: string): void {
|
||||
this.queueEvent({
|
||||
event_type: "page_view",
|
||||
metadata: {
|
||||
page,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra evento customizado (para eventos que não têm método específico)
|
||||
*/
|
||||
trackCustomEvent(
|
||||
eventType: TelemetryEventType | string,
|
||||
adId?: number,
|
||||
metadata?: Record<string, any>
|
||||
): void {
|
||||
this.queueEvent({
|
||||
event_type: eventType as TelemetryEventType,
|
||||
ad_id: adId,
|
||||
metadata: {
|
||||
...metadata,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adiciona evento à fila
|
||||
*/
|
||||
private queueEvent(event: TelemetryEvent): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
this.eventQueue.push({
|
||||
...event,
|
||||
session_id: this.sessionId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Enviar imediatamente se a fila estiver cheia
|
||||
if (this.eventQueue.length >= this.MAX_BATCH_SIZE) {
|
||||
this.flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia eventos em batch para o servidor
|
||||
*/
|
||||
async flushQueue(): Promise<void> {
|
||||
if (this.eventQueue.length === 0) return;
|
||||
|
||||
const eventsToSend = [...this.eventQueue];
|
||||
this.eventQueue = [];
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/telemetry", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
events: eventsToSend.map(({ timestamp, ...event }) => ({
|
||||
...event,
|
||||
session_id: this.sessionId,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Re-adicionar eventos à fila se falhar (mas não logar erro para não poluir console)
|
||||
this.eventQueue.unshift(...eventsToSend);
|
||||
// Silenciar erro para não gerar mensagens no console
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-adicionar eventos à fila se falhar (mas não logar erro)
|
||||
this.eventQueue.unshift(...eventsToSend);
|
||||
// Silenciar erro para não gerar mensagens no console
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia intervalo de envio automático
|
||||
*/
|
||||
private startBatchInterval(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
this.batchInterval = setInterval(() => {
|
||||
this.flushQueue();
|
||||
}, this.BATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Para o intervalo de envio automático
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.batchInterval) {
|
||||
clearInterval(this.batchInterval);
|
||||
this.batchInterval = null;
|
||||
}
|
||||
// Enviar eventos pendentes antes de parar
|
||||
this.flushQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configura handler para enviar eventos antes de sair da página
|
||||
*/
|
||||
private setupPageUnloadHandler(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Navigator.sendBeacon é mais confiável para page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (this.eventQueue.length === 0) return;
|
||||
|
||||
try {
|
||||
const eventsToSend = this.eventQueue.map(({ timestamp, ...event }) => event);
|
||||
|
||||
// Usar sendBeacon para garantir envio
|
||||
const eventsWithSession = eventsToSend.map(({ timestamp, ...event }) => ({
|
||||
...event,
|
||||
session_id: this.sessionId,
|
||||
}));
|
||||
|
||||
// Usar Blob para evitar problemas de message channel
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ events: eventsWithSession })],
|
||||
{ type: "application/json" }
|
||||
);
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/telemetry", blob);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silenciar erro para não gerar mensagens no console
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback para visibilitychange
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
this.flushQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza user_id (útil após login)
|
||||
*/
|
||||
setUserId(userId: number | undefined): void {
|
||||
this.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
// Instância singleton
|
||||
let telemetryInstance: MarketTelemetry | null = null;
|
||||
|
||||
/**
|
||||
* Obtém ou cria instância singleton de telemetria
|
||||
*/
|
||||
export function getTelemetry(userId?: number): MarketTelemetry {
|
||||
if (!telemetryInstance) {
|
||||
telemetryInstance = new MarketTelemetry(userId);
|
||||
} else if (userId !== undefined) {
|
||||
telemetryInstance.setUserId(userId);
|
||||
}
|
||||
return telemetryInstance;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,8 @@ export const characterSchema = z.object({
|
||||
|
||||
// Schema para criar anúncio (listing)
|
||||
export const createListingSchema = z.object({
|
||||
item_id: z.number().int().positive("ID do item deve ser positivo"),
|
||||
item_id: z.number().int().positive("ID do item deve ser positivo").optional(), // ID legado (SQLite) - opcional
|
||||
item_id_unified: z.number().int().positive("ID unificado do item deve ser positivo").optional(), // ID unificado da tabela items - preferido
|
||||
item_name: z
|
||||
.string()
|
||||
.min(1, "Nome do item é obrigatório")
|
||||
@@ -296,13 +297,62 @@ export const createListingSchema = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => val?.trim() || null),
|
||||
}).refine(
|
||||
// Campos para troca por item
|
||||
trade_item_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive("ID do item de troca deve ser positivo")
|
||||
.optional()
|
||||
.nullable(),
|
||||
trade_item_name: z
|
||||
.string()
|
||||
.min(1, "Nome do item de troca é obrigatório se trade_item_id for fornecido")
|
||||
.max(255, "Nome do item de troca deve ter no máximo 255 caracteres")
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((name) => name?.trim() || null),
|
||||
trade_item_quantity: z
|
||||
.number()
|
||||
.int()
|
||||
.positive("Quantidade do item de troca deve ser maior que zero")
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(1),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Pelo menos um preço deve ser fornecido
|
||||
// Pelo menos um ID (item_id ou item_id_unified) deve ser fornecido
|
||||
if (!data.item_id && !data.item_id_unified) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Pelo menos um ID do item (item_id ou item_id_unified) deve ser fornecido",
|
||||
path: ["item_id"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// Se trade_item_id for fornecido, trade_item_name também deve ser
|
||||
if (data.trade_item_id && !data.trade_item_name) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Nome do item de troca é obrigatório quando ID é fornecido",
|
||||
path: ["trade_item_name"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// Pelo menos um preço ou troca por item deve ser fornecido
|
||||
return (
|
||||
data.price_pix !== null ||
|
||||
data.price_tibia_coins !== null ||
|
||||
data.price_gold_coins !== null
|
||||
data.price_gold_coins !== null ||
|
||||
data.trade_item_id !== null
|
||||
);
|
||||
},
|
||||
{
|
||||
@@ -354,6 +404,41 @@ export const bidSchema = z.object({
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SCHEMAS DE TELEMETRIA
|
||||
// ============================================
|
||||
|
||||
// Schema para evento de telemetria
|
||||
export const telemetryEventSchema = z.object({
|
||||
event_type: z.enum([
|
||||
"ad_view",
|
||||
"ad_click",
|
||||
"ad_exit",
|
||||
"search",
|
||||
"filter",
|
||||
"favorite",
|
||||
"unfavorite",
|
||||
"purchase",
|
||||
"purchase_intention_created",
|
||||
"purchase_intention_viewed",
|
||||
"bid_placed",
|
||||
"bid_viewed",
|
||||
"seller_profile_viewed",
|
||||
"inquiry",
|
||||
"page_view",
|
||||
"price_change",
|
||||
]),
|
||||
ad_id: z.number().int().positive().optional().nullable(),
|
||||
session_id: z.string().optional().nullable(),
|
||||
metadata: z.any().optional(),
|
||||
});
|
||||
|
||||
// Schema para batch de eventos (inclui session_id no nível do batch)
|
||||
export const telemetryBatchSchema = z.object({
|
||||
events: z.array(telemetryEventSchema).min(1).max(100), // Limite de 100 eventos por batch
|
||||
session_id: z.string().optional().nullable(), // session_id pode estar no nível do batch
|
||||
});
|
||||
|
||||
// Type exports para uso no código
|
||||
export type CreateDonationInput = z.infer<typeof createDonationSchema>;
|
||||
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
|
||||
@@ -365,3 +450,5 @@ export type CharacterInput = z.infer<typeof characterSchema>;
|
||||
export type CreateListingInput = z.infer<typeof createListingSchema>;
|
||||
export type UpdateListingInput = z.infer<typeof updateListingSchema>;
|
||||
export type BidInput = z.infer<typeof bidSchema>;
|
||||
export type TelemetryEventInput = z.infer<typeof telemetryEventSchema>;
|
||||
export type TelemetryBatchInput = z.infer<typeof telemetryBatchSchema>;
|
||||
Reference in new issue
Block a user