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
@@ -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'
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user