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:
Evandro Fonseca Junior committed 2025-11-05 01:58:03 -03:00
1 parent 55eebde0ba
commit ab37b477df
106 files changed
+15261 -301

No files matched your search

+28 -5
View File
@@ -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';
+25 -19
View File
@@ -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);
});
+21
View File
@@ -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
*/
+138
View File
@@ -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
View File
@@ -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();
}
}
+150
View File
@@ -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;
}
+656
View File
@@ -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'
`
);
}