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

+24
View File
@@ -0,0 +1,24 @@
/**
* Prisma Client para PostgreSQL (Banco Principal da Aplicação)
* Usa DATABASE_URL_APP do .env
*/
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Queries para banco SQLite de itens usando Prisma
* Como o banco tem tabelas dinâmicas (uma por categoria),
* usamos $queryRaw para queries dinâmicas
*/
import { itemsPrisma } from "./items";
import { enToPtCategoryMap } from "../item-images";
import { Prisma } from "@prisma/client";
export interface SearchParams {
q?: string;
categories?: string[];
rarities?: string[];
sort?:
| "name"
| "level-asc"
| "level-desc"
| "weight-asc"
| "weight-desc"
| "category";
vocation?: string;
page?: number;
pageSize?: number;
}
export interface SearchResult<T> {
items: T[];
total: number;
}
let schemaLogged = false;
// Tenta deduzir ordenação segura a partir do parâmetro de sort
function getOrderBy(sort: SearchParams["sort"]): string {
switch (sort) {
case "level-asc":
return "level ASC, name ASC";
case "level-desc":
return "level DESC, name ASC";
case "weight-asc":
return "weight ASC, name ASC";
case "weight-desc":
return "weight DESC, name ASC";
case "category":
return "category ASC, name ASC";
case "name":
default:
return "name ASC";
}
}
/**
* Busca itens no banco SQLite usando Prisma $queryRaw
*/
export async function searchItems<T = any>(
params: SearchParams
): Promise<SearchResult<T>> {
const {
q = "",
categories = [],
rarities = [],
sort = "name",
vocation,
page = 1,
pageSize = 24,
} = params;
const safePage = Math.max(1, page);
const safePageSize = Math.min(Math.max(1, pageSize), 50);
const offset = (safePage - 1) * safePageSize;
try {
// Log básico do schema na primeira chamada
if (!schemaLogged) {
try {
const tables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`;
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
schemaLogged = true;
} catch (intErr) {
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
schemaLogged = true;
}
}
// Buscar todas as tabelas disponíveis
const allTables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'`;
// Filtra por categorias solicitadas (EN → PT)
const allowedPt = new Set(
categories.length > 0
? categories.map((en) => enToPtCategoryMap[en] || en)
: allTables.map((t) => t.name)
);
const candidateTables = allTables
.map((t) => t.name)
.filter((name) => allowedPt.has(name));
const selects: string[] = [];
const bindings: any[] = [];
for (const tableName of candidateTables) {
try {
// Verificar colunas da tabela
const cols = await itemsPrisma.$queryRaw<
Array<{ name: string; type: string }>
>`PRAGMA table_info(${Prisma.raw(tableName)})`;
const colNames = new Set(cols.map((c) => (c?.name || "").toLowerCase()));
// Requer colunas mínimas
if (
!colNames.has("id") ||
!colNames.has("nome") ||
!colNames.has("image_url")
) {
continue;
}
// Se filtro de vocação foi solicitado e a tabela não possui coluna 'voc', ignora
if (vocation && !colNames.has("voc")) {
continue;
}
const tableWhere: string[] = [];
const tableBindings: any[] = [];
if (q && colNames.has("nome")) {
tableWhere.push("LOWER(nome) LIKE ?");
tableBindings.push(`%${q.toLowerCase()}%`);
}
if (vocation && colNames.has("voc")) {
const sel = vocation.toLowerCase();
tableWhere.push("REPLACE(LOWER(voc), ' ', '') = REPLACE(?, ' ', '')");
tableBindings.push(sel);
}
const whereSqlTbl =
tableWhere.length > 0 ? `WHERE ${tableWhere.join(" AND ")}` : "";
// Mapear colunas dinâmicas
const levelCol =
colNames.has("nivel") || colNames.has("lvl")
? `COALESCE(${colNames.has("nivel") ? "nivel" : "NULL"}, ${colNames.has("lvl") ? "lvl" : "NULL"})`
: "NULL";
const weightCol =
colNames.has("peso") || colNames.has("peso_em_oz")
? `COALESCE(${colNames.has("peso") ? "peso" : "NULL"}, ${colNames.has("peso_em_oz") ? "peso_em_oz" : "NULL"})`
: "NULL";
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
const skillCol = colNames.has("skill_para_usar")
? "skill_para_usar"
: "NULL";
// Campos extras opcionais
const extraCols = [
"lvl",
"voc",
"volume",
"bonus_de_skill",
"protecao",
"dropa_de",
] as const;
const extraSelect = extraCols
.map((c) => (colNames.has(c) ? `${c}` : `NULL AS ${c}`))
.join(", ");
const selectSql = `SELECT
id,
nome AS name,
'${tableName}' AS category,
image_url AS image,
${levelCol} AS level,
${weightCol} AS weight,
${slotsCol} AS slots,
${skillCol} AS skill${extraSelect ? ", " + extraSelect : ""}
FROM ${tableName} ${whereSqlTbl}`;
selects.push(selectSql);
bindings.push(...tableBindings);
} catch (e) {
console.warn(`[items-db] Ignorando tabela ${tableName}:`, (e as any)?.message);
}
}
if (selects.length === 0) {
console.warn("[items-db] Nenhuma tabela compatível encontrada");
return { items: [], total: 0 } as SearchResult<T>;
}
const unionSql = selects.join(" UNION ALL ");
const orderBy = getOrderBy(sort);
// Contagem total
const countSql = `SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`;
const countResult = await itemsPrisma.$queryRaw<
Array<{ cnt: number }>
>(Prisma.raw(countSql), ...bindings);
const total = countResult[0]?.cnt ?? 0;
// Buscar itens com paginação
const selectSql = `SELECT * FROM (${unionSql}) AS u ORDER BY ${orderBy} LIMIT ? OFFSET ?`;
const items = await itemsPrisma.$queryRaw<T>(
Prisma.raw(selectSql),
...bindings,
safePageSize,
offset
);
return { items, total };
} catch (error) {
console.error("[items-db] Erro ao buscar itens:", error);
return { items: [], total: 0 } as SearchResult<T>;
}
}
/**
* Busca um item por ID no banco de dados
* Busca em todas as tabelas de categorias até encontrar o item
*/
export async function getItemById(itemId: number): Promise<any | null> {
try {
// Buscar em todas as tabelas de categorias
const tables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
for (const table of tables) {
try {
const item = await itemsPrisma.$queryRaw<any>(
Prisma.raw(`SELECT * FROM ${table.name} WHERE id = ?`),
itemId
);
if (item && item.length > 0) {
return item[0];
}
} catch (err) {
// Ignorar erros de tabelas que não têm coluna 'id' ou outros problemas
continue;
}
}
return null;
} catch (error) {
console.error("[items-db] Erro ao buscar item por ID:", error);
return null;
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Prisma Client para SQLite (Banco de Itens do TibiaWiki)
*
* NOTA: Por enquanto, mantemos better-sqlite3 para queries SQLite
* devido às limitações do Prisma com tabelas dinâmicas.
*
* Este arquivo está preparado para migração futura quando necessário.
*/
// Por enquanto, não exportamos nada - usando better-sqlite3 diretamente
// export const itemsPrisma = ... será implementado quando migrarmos SQLite
+94
View File
@@ -0,0 +1,94 @@
/**
* Queries de bids usando Prisma
* Migrado de lib/db/queries/bids.ts
*/
import { prisma } from "../app";
export interface Bid {
bid_id: number;
listing_id: number;
bidder_id: number;
bid_amount: number;
bid_type: "pix" | "tibia_coins" | "gold_coins" | null;
created_at: Date;
is_active: boolean;
}
export interface CreateBidData {
listing_id: number;
bidder_id: number;
bid_amount: number;
bid_type?: "pix" | "tibia_coins" | "gold_coins";
}
/**
* Cria um novo lance
*/
export async function createBid(data: CreateBidData): Promise<Bid> {
return await prisma.bid.create({
data: {
listing_id: data.listing_id,
bidder_id: data.bidder_id,
bid_amount: data.bid_amount,
bid_type: data.bid_type || null,
},
});
}
/**
* Busca lances de um anúncio
*/
export async function getBidsByListing(
listingId: number,
activeOnly: boolean = true
): Promise<Bid[]> {
return await prisma.bid.findMany({
where: {
listing_id: listingId,
...(activeOnly ? { is_active: true } : {}),
},
orderBy: [
{ bid_amount: "desc" },
{ created_at: "desc" },
],
});
}
/**
* Busca o maior lance de um anúncio
*/
export async function getHighestBid(listingId: number): Promise<Bid | null> {
return await prisma.bid.findFirst({
where: {
listing_id: listingId,
is_active: true,
},
orderBy: [
{ bid_amount: "desc" },
{ created_at: "desc" },
],
});
}
/**
* Cancela um lance (desativa)
*/
export async function cancelBid(bidId: number): Promise<boolean> {
const result = await prisma.bid.updateMany({
where: { bid_id: bidId },
data: { is_active: false },
});
return result.count > 0;
}
/**
* Busca lance por ID
*/
export async function getBidById(bidId: number): Promise<Bid | null> {
return await prisma.bid.findUnique({
where: { bid_id: bidId },
});
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Queries de personagens usando Prisma
* Migrado de lib/db/queries/characters.ts
*/
import { prisma } from "../app";
export interface Character {
character_id: number;
user_id: number;
character_name: string;
server_name: string;
is_main: boolean;
verified: boolean;
created_at: Date;
}
export interface CreateCharacterData {
character_name: string;
server_name: string;
is_main?: boolean;
}
export interface UpdateCharacterData {
character_name?: string;
server_name?: string;
is_main?: boolean;
}
/**
* Cria um novo personagem
* Se isMain=true, desmarca outros personagens como main
*/
export async function createCharacter(
userId: number,
data: CreateCharacterData
): Promise<Character> {
return await prisma.$transaction(async (tx) => {
// Se este personagem será main, desmarcar outros
if (data.is_main) {
await tx.userCharacter.updateMany({
where: { user_id: userId },
data: { is_main: false },
});
}
// Inserir novo personagem
return await tx.userCharacter.create({
data: {
user_id: userId,
character_name: data.character_name,
server_name: data.server_name,
is_main: data.is_main || false,
},
});
});
}
/**
* Lista todos os personagens de um usuário
*/
export async function getUserCharacters(userId: number): Promise<Character[]> {
return await prisma.userCharacter.findMany({
where: { user_id: userId },
orderBy: [
{ is_main: "desc" },
{ created_at: "asc" },
],
});
}
/**
* Busca personagem principal de um usuário em um servidor específico
*/
export async function getMainCharacterByUserId(
userId: number,
serverName: string
): Promise<Character | null> {
return await prisma.userCharacter.findFirst({
where: {
user_id: userId,
server_name: serverName,
},
orderBy: [
{ is_main: "desc" },
{ created_at: "asc" },
],
});
}
/**
* Busca personagem por ID
*/
export async function getCharacterById(
characterId: number
): Promise<Character | null> {
return await prisma.userCharacter.findUnique({
where: { character_id: characterId },
});
}
/**
* Atualiza personagem
* Verifica ownership e se isMain=true, desmarca outros
*/
export async function updateCharacter(
characterId: number,
userId: number,
data: UpdateCharacterData
): Promise<Character> {
// Verificar ownership primeiro
const existing = await getCharacterById(characterId);
if (!existing) {
throw new Error("Personagem não encontrado");
}
if (existing.user_id !== userId) {
throw new Error("Você não tem permissão para atualizar este personagem");
}
return await prisma.$transaction(async (tx) => {
// Se este personagem será main, desmarcar outros
if (data.is_main) {
await tx.userCharacter.updateMany({
where: {
user_id: userId,
character_id: { not: characterId },
},
data: { is_main: false },
});
}
// Atualizar personagem
const updateData: any = {};
if (data.character_name !== undefined) updateData.character_name = data.character_name;
if (data.server_name !== undefined) updateData.server_name = data.server_name;
if (data.is_main !== undefined) updateData.is_main = data.is_main;
if (Object.keys(updateData).length === 0) {
return existing;
}
return await tx.userCharacter.update({
where: { character_id: characterId },
data: updateData,
});
});
}
/**
* Remove personagem
* Verifica ownership antes de remover
*/
export async function deleteCharacter(
characterId: number,
userId: number
): Promise<void> {
// Verificar ownership
const existing = await getCharacterById(characterId);
if (!existing) {
throw new Error("Personagem não encontrado");
}
if (existing.user_id !== userId) {
throw new Error("Você não tem permissão para remover este personagem");
}
await prisma.userCharacter.delete({
where: { character_id: characterId },
});
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Queries de favoritos usando Prisma
*/
import { prisma } from "../app";
/**
* Verifica se um anúncio está favoritado por um usuário
*/
export async function isFavorite(userId: number, listingId: number): Promise<boolean> {
const favorite = await prisma.userFavorite.findUnique({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
});
return !!favorite;
}
/**
* Favorita um anúncio
*/
export async function addFavorite(userId: number, listingId: number): Promise<void> {
await prisma.userFavorite.upsert({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
create: {
user_id: userId,
ad_id: listingId,
},
update: {}, // Se já existe, não faz nada
});
}
/**
* Remove favorito de um anúncio
*/
export async function removeFavorite(userId: number, listingId: number): Promise<void> {
await prisma.userFavorite.delete({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
}).catch(() => {
// Ignorar erro se não existir (idempotente)
});
}
/**
* Toggle favorite (favorita se não estiver, desfavorita se estiver)
*/
export async function toggleFavorite(userId: number, listingId: number): Promise<boolean> {
const isFavorited = await isFavorite(userId, listingId);
if (isFavorited) {
await removeFavorite(userId, listingId);
return false;
} else {
await addFavorite(userId, listingId);
return true;
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Queries de itens usando Prisma
* Busca itens da tabela unificada items (PostgreSQL)
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export interface Item {
item_id: number;
source_table_id: number;
source_table_name: string;
item_name: string;
category: string;
image_local_path: string | null;
image_url: string | null;
rarity: string | null;
level: number | null;
weight: number | null;
slots: number | null;
skill: string | null;
metadata: any;
created_at: Date;
updated_at: Date;
}
export interface SearchItemsParams {
q?: string;
category?: string;
rarity?: string;
page?: number;
pageSize?: number;
}
/**
* Busca um item por ID unificado
*/
export async function getItemById(itemId: number): Promise<Item | null> {
const item = await prisma.item.findUnique({
where: { item_id: itemId },
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
/**
* Busca um item por nome (exato ou similar)
*/
export async function getItemByName(itemName: string): Promise<Item | null> {
const item = await prisma.item.findFirst({
where: {
item_name: {
equals: itemName,
mode: "insensitive",
},
},
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
/**
* Busca itens com filtros
*/
export async function searchItems(
params: SearchItemsParams = {}
): Promise<{ items: Item[]; total: number }> {
const {
q = "",
category,
rarity,
page = 1,
pageSize = 24,
} = params;
const where: Prisma.ItemWhereInput = {};
if (q) {
where.item_name = {
contains: q,
mode: "insensitive",
};
}
if (category) {
where.category = category;
}
if (rarity) {
where.rarity = rarity;
}
const total = await prisma.item.count({ where });
const items = await prisma.item.findMany({
where,
orderBy: { item_name: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
items: items.map((item) => ({
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
})) as Item[],
total,
};
}
/**
* Busca item por source_table_name e source_table_id
* (útil para migração ou compatibilidade com IDs antigos)
*/
export async function getItemBySource(
sourceTableName: string,
sourceTableId: number
): Promise<Item | null> {
const item = await prisma.item.findUnique({
where: {
source_table_name_source_table_id: {
source_table_name: sourceTableName,
source_table_id: sourceTableId,
},
},
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
+589
View File
@@ -0,0 +1,589 @@
/**
* Queries de listings (anúncios) usando Prisma
* Migrado de lib/db/queries/listings.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
// Re-exportar tipos para compatibilidade
export type ListingStatus = "active" | "sold" | "cancelled" | "in_escrow" | "expired";
export interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | null;
price_tibia_coins: number | null;
price_gold_coins: number | null;
is_auction: boolean;
current_bid_amount: number | null;
current_bidder_id: number | null;
auction_ends_at: Date | null;
description: string | null;
listing_status: ListingStatus;
created_at: Date;
updated_at: Date;
sold_at: Date | null;
views_count: number;
is_featured?: boolean;
favorites_count?: number;
inquiries_count?: number;
price_history?: any;
expires_at?: Date | null;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
seller_character_name?: string | null;
}
export interface CreateListingData {
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
is_auction?: boolean;
auction_ends_at?: Date;
description?: string;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
min_bid_increment?: number;
}
export interface UpdateListingData {
quantity?: number;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
description?: string;
listing_status?: ListingStatus;
current_bid_amount?: number;
current_bidder_id?: number;
}
export interface ListingFilters {
status?: ListingStatus;
server_name?: string;
item_id?: number;
seller_id?: number;
is_auction?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: "created_at" | "price_pix" | "price_tibia_coins" | "price_gold_coins" | "views_count";
sortOrder?: "ASC" | "DESC";
}
/**
* Cria um novo anúncio
*/
export async function createListing(
data: CreateListingData
): Promise<Listing> {
// Calcular incremento mínimo se for leilão e não foi fornecido
let minBidIncrement = data.min_bid_increment;
if (data.is_auction && !minBidIncrement) {
const initialPrice = data.price_pix || 0;
minBidIncrement = Math.max(initialPrice * 0.05, 10.0);
}
const listing = await prisma.listing.create({
data: {
seller_id: data.seller_id,
item_id: data.item_id,
item_name: data.item_name,
quantity: data.quantity,
server_name: data.server_name,
price_pix: data.price_pix || null,
price_tibia_coins: data.price_tibia_coins || null,
price_gold_coins: data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
is_auction: data.is_auction || false,
auction_ends_at: data.auction_ends_at || null,
description: data.description || null,
trade_item_id: data.trade_item_id || null,
trade_item_name: data.trade_item_name || null,
trade_item_quantity: data.trade_item_quantity || null,
min_bid_increment: minBidIncrement || null,
initial_price_pix: data.is_auction ? (data.price_pix || null) : null,
initial_price_tibia_coins: data.is_auction ? (data.price_tibia_coins || null) : null,
initial_price_gold_coins: data.is_auction && data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
},
});
// Buscar character name separadamente
const character = await prisma.userCharacter.findFirst({
where: {
user_id: data.seller_id,
server_name: data.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Busca anúncio por ID
*/
export async function getListingById(listingId: number): Promise<Listing | null> {
const listing = await prisma.listing.findUnique({
where: { listing_id: listingId },
});
if (!listing) {
return null;
}
// Buscar character name separadamente
const character = await prisma.userCharacter.findFirst({
where: {
user_id: listing.seller_id,
server_name: listing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Lista anúncios com filtros e paginação
*/
export async function getListings(
filters: ListingFilters = {}
): Promise<{ listings: Listing[]; total: number }> {
const {
status,
server_name,
item_id,
seller_id,
is_auction,
search,
page = 1,
pageSize = 20,
sortBy = "created_at",
sortOrder = "DESC",
} = filters;
const where: Prisma.ListingWhereInput = {};
if (status) {
where.listing_status = status;
}
if (server_name) {
where.server_name = server_name;
}
if (item_id) {
where.item_id = item_id;
}
if (seller_id) {
where.seller_id = seller_id;
}
if (is_auction !== undefined) {
where.is_auction = is_auction;
}
if (search) {
where.OR = [
{ item_name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
];
}
// Validar sortBy
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const orderBy: Prisma.ListingOrderByWithRelationInput = {
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
};
// Contar total
const total = await prisma.listing.count({ where });
// Buscar listings com paginação
const listings = await prisma.listing.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
// Buscar character names para cada listing (otimizado com uma query por seller_id + server_name)
const sellerServerPairs = new Set(
listings.map((l) => `${l.seller_id}:${l.server_name}`)
);
const charactersMap = new Map<string, string>();
for (const pair of sellerServerPairs) {
const [sellerId, serverName] = pair.split(":");
const character = await prisma.userCharacter.findFirst({
where: {
user_id: parseInt(sellerId, 10),
server_name: serverName,
is_main: true,
},
});
if (character) {
charactersMap.set(pair, character.character_name);
}
}
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
} as Listing;
});
return {
listings: listingsWithCharacterNames,
total,
};
}
/**
* Atualiza anúncio
*/
export async function updateListing(
listingId: number,
data: UpdateListingData
): Promise<Listing | null> {
const updateData: Prisma.ListingUpdateInput = {};
if (data.quantity !== undefined) updateData.quantity = data.quantity;
if (data.price_pix !== undefined) updateData.price_pix = data.price_pix;
if (data.price_tibia_coins !== undefined) updateData.price_tibia_coins = data.price_tibia_coins;
if (data.price_gold_coins !== undefined) updateData.price_gold_coins = BigInt(data.price_gold_coins);
if (data.description !== undefined) updateData.description = data.description;
if (data.listing_status !== undefined) updateData.listing_status = data.listing_status;
if (data.current_bid_amount !== undefined) updateData.current_bid_amount = data.current_bid_amount;
if (data.current_bidder_id !== undefined) {
updateData.current_bidder_id = data.current_bidder_id;
}
const listing = await prisma.listing.update({
where: { listing_id: listingId },
data: updateData,
});
// Buscar character name
const character = await prisma.userCharacter.findFirst({
where: {
user_id: listing.seller_id,
server_name: listing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Remove anúncio
*/
export async function deleteListing(listingId: number): Promise<boolean> {
try {
await prisma.listing.delete({
where: { listing_id: listingId },
});
return true;
} catch (error) {
return false;
}
}
/**
* Incrementa contador de visualizações
*/
export async function incrementViews(listingId: number): Promise<void> {
await prisma.listing.update({
where: { listing_id: listingId },
data: {
views_count: {
increment: 1,
},
},
});
}
/**
* Lista anúncios de um vendedor
*/
export async function getListingsBySeller(
sellerId: number,
filters: { status?: ListingStatus; page?: number; pageSize?: number } = {}
): Promise<{ listings: Listing[]; total: number }> {
return getListings({
seller_id: sellerId,
status: filters.status,
page: filters.page,
pageSize: filters.pageSize,
});
}
/**
* Lista apenas anúncios ativos (incluindo vendidos nas últimas 1 hora)
*/
export async function getActiveListings(
filters: Omit<ListingFilters, "status"> = {}
): Promise<{ listings: Listing[]; total: number }> {
const {
server_name,
item_id,
seller_id,
is_auction,
search,
page = 1,
pageSize = 20,
sortBy = "created_at",
sortOrder = "DESC",
} = filters;
const where: Prisma.ListingWhereInput = {
OR: [
// Anúncios ativos
{ listing_status: "active" },
// Anúncios vendidos nas últimas 1 hora
{
listing_status: "sold",
sold_at: {
gte: new Date(Date.now() - 60 * 60 * 1000), // 1 hora atrás
},
},
],
};
if (server_name) {
where.server_name = server_name;
}
if (item_id) {
where.item_id = item_id;
}
if (seller_id) {
where.seller_id = seller_id;
}
if (is_auction !== undefined) {
where.is_auction = is_auction;
}
if (search) {
const searchCondition = {
OR: [
{ item_name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
],
};
// Combinar com a condição OR existente
where.AND = [
{
OR: [
{ listing_status: "active" },
{
listing_status: "sold",
sold_at: {
gte: new Date(Date.now() - 60 * 60 * 1000),
},
},
],
},
searchCondition,
];
delete where.OR;
}
// Validar sortBy
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const orderBy: Prisma.ListingOrderByWithRelationInput = {
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
};
// Contar total
const total = await prisma.listing.count({ where });
// Buscar listings com paginação
const listings = await prisma.listing.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
// Buscar character names para cada listing
const sellerServerPairs = new Set(
listings.map((l) => `${l.seller_id}:${l.server_name}`)
);
const charactersMap = new Map<string, string>();
for (const pair of sellerServerPairs) {
const [sellerId, serverName] = pair.split(":");
const character = await prisma.userCharacter.findFirst({
where: {
user_id: parseInt(sellerId, 10),
server_name: serverName,
is_main: true,
},
});
if (character) {
charactersMap.set(pair, character.character_name);
}
}
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
} as Listing;
});
return {
listings: listingsWithCharacterNames,
total,
};
}
/**
* Coloca um lance em um leilão
*/
export async function placeBid(
listingId: number,
bidderId: number,
amount: number,
paymentMethod: "pix" | "tc" | "gp"
): Promise<{ success: boolean; listing?: Listing; error?: string; previousBidderId?: number }> {
// Usar transação para garantir atomicidade
return await prisma.$transaction(async (tx) => {
// Buscar listing atual
const listing = await tx.listing.findUnique({
where: { listing_id: listingId },
});
if (!listing) {
return { success: false, error: "Anúncio não encontrado" };
}
if (!listing.is_auction) {
return { success: false, error: "Este anúncio não é um leilão" };
}
if (listing.listing_status !== "active") {
return { success: false, error: "Leilão não está ativo" };
}
if (listing.seller_id === bidderId) {
return { success: false, error: "Você não pode dar lance no seu próprio anúncio" };
}
// Validar valor mínimo
const minIncrement = listing.min_bid_increment ? Number(listing.min_bid_increment) : 10;
const currentBid = listing.current_bid_amount ? Number(listing.current_bid_amount) : 0;
const minBid = currentBid + minIncrement;
if (amount < minBid) {
return { success: false, error: `O lance deve ser de pelo menos ${minBid.toFixed(2)}` };
}
const previousBidderId = listing.current_bidder_id;
// Atualizar listing
const updatedListing = await tx.listing.update({
where: { listing_id: listingId },
data: {
current_bid_amount: amount,
current_bidder_id: bidderId,
},
});
// Buscar character name
const character = await tx.userCharacter.findFirst({
where: {
user_id: updatedListing.seller_id,
server_name: updatedListing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
success: true,
listing: {
...updatedListing,
price_pix: updatedListing.price_pix ? Number(updatedListing.price_pix) : null,
current_bid_amount: updatedListing.current_bid_amount ? Number(updatedListing.current_bid_amount) : null,
min_bid_increment: updatedListing.min_bid_increment ? Number(updatedListing.min_bid_increment) : null,
initial_price_pix: updatedListing.initial_price_pix ? Number(updatedListing.initial_price_pix) : null,
price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null,
initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing,
previousBidderId: previousBidderId || undefined,
};
});
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Queries de notificações usando Prisma
* Migrado de lib/db/queries/notifications.ts
*/
import { prisma } from "../app";
export type NotificationType = "bid" | "sale" | "purchase" | "escrow" | "dispute" | "system";
export interface Notification {
notification_id: number;
user_id: number;
notification_type: NotificationType;
title: string;
message: string;
related_id: number | null;
is_read: boolean;
send_whatsapp: boolean;
whatsapp_sent: boolean;
created_at: Date;
}
export interface CreateNotificationData {
user_id: number;
notification_type: NotificationType;
title: string;
message: string;
related_id?: number | null;
send_whatsapp?: boolean;
}
/**
* Cria uma nova notificação
*/
export async function createNotification(
data: CreateNotificationData
): Promise<Notification> {
return await prisma.notification.create({
data: {
user_id: data.user_id,
notification_type: data.notification_type,
title: data.title,
message: data.message,
related_id: data.related_id || null,
send_whatsapp: data.send_whatsapp || false,
},
});
}
/**
* Busca notificações de um usuário
*/
export async function getUserNotifications(
userId: number,
filters: { is_read?: boolean; type?: NotificationType; limit?: number } = {}
): Promise<Notification[]> {
const { is_read, type, limit = 50 } = filters;
const where: any = { user_id: userId };
if (is_read !== undefined) {
where.is_read = is_read;
}
if (type) {
where.notification_type = type;
}
return await prisma.notification.findMany({
where,
orderBy: { created_at: "desc" },
take: limit,
});
}
/**
* Conta notificações não lidas de um usuário
*/
export async function getUnreadCount(userId: number): Promise<number> {
return await prisma.notification.count({
where: {
user_id: userId,
is_read: false,
},
});
}
/**
* Marca notificação como lida
*/
export async function markAsRead(notificationId: number, userId: number): Promise<boolean> {
const result = await prisma.notification.updateMany({
where: {
notification_id: notificationId,
user_id: userId,
},
data: {
is_read: true,
},
});
return result.count > 0;
}
/**
* Marca todas as notificações de um usuário como lidas
*/
export async function markAllAsRead(userId: number): Promise<number> {
const result = await prisma.notification.updateMany({
where: {
user_id: userId,
is_read: false,
},
data: {
is_read: true,
},
});
return result.count;
}
/**
* Remove notificação
*/
export async function deleteNotification(notificationId: number, userId: number): Promise<boolean> {
try {
await prisma.notification.delete({
where: {
notification_id: notificationId,
user_id: userId,
},
});
return true;
} catch (error) {
return false;
}
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Queries de Purchase Intentions usando Prisma
*/
import { prisma } from "../app";
export type PurchaseIntentionStatus = "pending" | "accepted" | "rejected" | "expired" | "completed";
export type PaymentMethod = "pix" | "tibia_coins" | "gold_coins" | "trade";
export interface CreatePurchaseIntentionData {
listing_id: number;
buyer_id: number;
payment_method: PaymentMethod;
buyer_message?: string;
expires_at?: Date;
}
export interface PurchaseIntention {
intention_id: number;
listing_id: number;
buyer_id: number;
payment_method: string;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
created_at: Date;
updated_at: Date;
expires_at: Date | null;
accepted_at: Date | null;
rejected_at: Date | null;
// Relacionamentos
listing?: {
listing_id: number;
item_name: string;
seller_id: number;
};
buyer?: {
user_id: number;
username: string;
email: string;
};
}
/**
* Cria uma nova intenção de compra
*/
export async function createPurchaseIntention(
data: CreatePurchaseIntentionData
): Promise<PurchaseIntention> {
return await prisma.purchaseIntention.create({
data: {
listing_id: data.listing_id,
buyer_id: data.buyer_id,
payment_method: data.payment_method,
buyer_message: data.buyer_message || null,
expires_at: data.expires_at || null,
status: "pending",
},
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention;
}
/**
* Busca intenções de um anúncio específico
*/
export async function getPurchaseIntentionsByListing(
listingId: number,
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
): Promise<PurchaseIntention[]> {
const { status, limit = 50 } = filters;
const where: any = { listing_id: listingId };
if (status) {
where.status = status;
}
return await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
}
/**
* Busca intenções de um comprador
*/
export async function getPurchaseIntentionsByBuyer(
buyerId: number,
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
): Promise<PurchaseIntention[]> {
const { status, limit = 50 } = filters;
const where: any = { buyer_id: buyerId };
if (status) {
where.status = status;
}
return await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
}
/**
* Busca uma intenção específica por ID
*/
export async function getPurchaseIntentionById(
intentionId: number
): Promise<PurchaseIntention | null> {
return await prisma.purchaseIntention.findUnique({
where: { intention_id: intentionId },
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention | null;
}
/**
* Atualiza o status de uma intenção
*/
export async function updatePurchaseIntentionStatus(
intentionId: number,
status: PurchaseIntentionStatus,
sellerResponse?: string
): Promise<PurchaseIntention> {
const updateData: any = {
status,
updated_at: new Date(),
};
if (status === "accepted") {
updateData.accepted_at = new Date();
updateData.seller_response = sellerResponse || null;
} else if (status === "rejected") {
updateData.rejected_at = new Date();
updateData.seller_response = sellerResponse || null;
} else if (sellerResponse) {
updateData.seller_response = sellerResponse;
}
return await prisma.purchaseIntention.update({
where: { intention_id: intentionId },
data: updateData,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention;
}
/**
* Conta intenções pendentes de um anúncio
*/
export async function getPendingIntentionsCount(listingId: number): Promise<number> {
return await prisma.purchaseIntention.count({
where: {
listing_id: listingId,
status: "pending",
},
});
}
/**
* Verifica se já existe uma intenção pendente para um listing+buyer
*/
export async function hasPendingIntention(
listingId: number,
buyerId: number
): Promise<boolean> {
const count = await prisma.purchaseIntention.count({
where: {
listing_id: listingId,
buyer_id: buyerId,
status: "pending",
},
});
return count > 0;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Queries de servidores usando Prisma
* Migrado de lib/db/queries/servers.ts
*/
import { prisma } from "../app";
export interface Server {
server_id: number;
server_name: string;
server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null;
location: "NA" | "SA" | "EU" | "OC" | "BR" | null;
is_active: boolean;
total_listings: number;
total_users: number;
total_subscribers: number;
total_transactions: number;
total_volume: number;
average_listing_price: number;
last_activity_at: Date | null;
created_at: Date;
updated_at: Date;
}
export interface ServerStats {
total_listings?: number;
total_users?: number;
total_subscribers?: number;
total_transactions?: number;
total_volume?: number;
average_listing_price?: number;
last_activity_at?: Date;
}
/**
* Lista todos os servidores
*/
export async function getAllServers(): Promise<Server[]> {
return await prisma.server.findMany({
orderBy: { server_name: "asc" },
});
}
/**
* Lista apenas servidores ativos
*/
export async function getActiveServers(): Promise<Server[]> {
return await prisma.server.findMany({
where: { is_active: true },
orderBy: { server_name: "asc" },
});
}
/**
* Busca servidor por nome
*/
export async function getServerByName(serverName: string): Promise<Server | null> {
return await prisma.server.findUnique({
where: { server_name: serverName },
});
}
/**
* Busca servidor por ID
*/
export async function getServerById(serverId: number): Promise<Server | null> {
return await prisma.server.findUnique({
where: { server_id: serverId },
});
}
/**
* Verifica se servidor existe e está ativo
*/
export async function isServerActive(serverName: string): Promise<boolean> {
const count = await prisma.server.count({
where: {
server_name: serverName,
is_active: true,
},
});
return count > 0;
}
/**
* Incrementa estatísticas do servidor
*/
export async function incrementServerStats(
serverName: string,
stats: Partial<{
total_listings: number;
total_users: number;
total_subscribers: number;
total_transactions: number;
total_volume: number;
}>
): Promise<void> {
const updateData: any = {
last_activity_at: new Date(),
};
if (stats.total_listings !== undefined) {
updateData.total_listings = { increment: stats.total_listings };
}
if (stats.total_users !== undefined) {
updateData.total_users = { increment: stats.total_users };
}
if (stats.total_subscribers !== undefined) {
updateData.total_subscribers = { increment: stats.total_subscribers };
}
if (stats.total_transactions !== undefined) {
updateData.total_transactions = { increment: stats.total_transactions };
}
if (stats.total_volume !== undefined) {
updateData.total_volume = { increment: stats.total_volume };
}
await prisma.server.update({
where: { server_name: serverName },
data: updateData,
});
}
/**
* Atualiza estatísticas completas do servidor
*/
export async function updateServerStats(
serverName: string,
stats: ServerStats
): Promise<void> {
const updateData: any = {};
if (stats.total_listings !== undefined) updateData.total_listings = stats.total_listings;
if (stats.total_users !== undefined) updateData.total_users = stats.total_users;
if (stats.total_subscribers !== undefined) updateData.total_subscribers = stats.total_subscribers;
if (stats.total_transactions !== undefined) updateData.total_transactions = stats.total_transactions;
if (stats.total_volume !== undefined) updateData.total_volume = stats.total_volume;
if (stats.average_listing_price !== undefined) updateData.average_listing_price = stats.average_listing_price;
if (stats.last_activity_at !== undefined) updateData.last_activity_at = stats.last_activity_at;
if (Object.keys(updateData).length === 0) {
return;
}
await prisma.server.update({
where: { server_name: serverName },
data: updateData,
});
}
/**
* Incrementa total_users de múltiplos servidores
*/
export async function incrementUsersForServers(serverNames: string[]): Promise<void> {
if (serverNames.length === 0) {
return;
}
await prisma.server.updateMany({
where: {
server_name: { in: serverNames },
},
data: {
total_users: { increment: 1 },
last_activity_at: new Date(),
},
});
}
+604
View File
@@ -0,0 +1,604 @@
/**
* Queries de Telemetria usando Prisma
* Migrado de lib/db/queries/telemetry.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "purchase_intention_created"
| "purchase_intention_viewed"
| "bid_placed"
| "bid_viewed"
| "seller_profile_viewed"
| "inquiry"
| "page_view"
| "price_change";
export interface TelemetryEvent {
event_id: number;
event_type: TelemetryEventType;
ad_id: number | null;
user_id: number | null;
session_id: string | null;
metadata: Record<string, any>;
timestamp: Date;
}
export interface TrackEventData {
event_type: TelemetryEventType;
ad_id?: number;
user_id?: number;
session_id?: string;
metadata?: Record<string, any>;
}
export interface AdAnalytics {
ad_id: number;
impressions: number;
clicks: number;
ctr: number;
avg_view_duration: number;
bounce_rate: number;
inquiries: number;
favorites: number;
conversion_rate: number;
time_to_sell: number | null;
last_updated: Date;
}
export interface UserBehavior {
user_id: number;
search_queries: string[];
filters_applied: Record<string, any>;
items_viewed: Array<{
item_id: number;
duration: number;
timestamp: Date;
}>;
items_favorited: number[];
purchases: Array<{
ad_id: number;
price: number;
timestamp: Date;
}>;
active_hours: Array<{
hour: number;
activity_count: number;
}>;
}
export interface MarketplaceStats {
total_transactions: number;
avg_price_by_category: Record<string, number>;
conversion_rate: number;
most_searched_items: Array<{
item_id: number;
item_name: string;
search_count: number;
}>;
most_sold_items: Array<{
item_id: number;
item_name: string;
sales_count: number;
}>;
peak_hours: Array<{
hour: number;
activity_count: number;
}>;
avg_time_to_sell: number;
}
/**
* Registra um evento de telemetria
*/
export async function trackEvent(
data: TrackEventData
): Promise<TelemetryEvent> {
return await prisma.telemetryEvent.create({
data: {
event_type: data.event_type,
ad_id: data.ad_id || null,
user_id: data.user_id || null,
session_id: data.session_id || null,
metadata: data.metadata || {},
},
});
}
/**
* Registra múltiplos eventos em batch
*/
export async function trackEventsBatch(
events: TrackEventData[]
): Promise<TelemetryEvent[]> {
if (events.length === 0) return [];
return await prisma.$transaction(
async (tx) => {
return await Promise.all(
events.map((event) =>
tx.telemetryEvent.create({
data: {
event_type: event.event_type,
ad_id: event.ad_id || null,
user_id: event.user_id || null,
session_id: event.session_id || null,
metadata: event.metadata || {},
},
})
)
);
}
);
}
/**
* Busca analytics de um anúncio específico
*/
export async function getAdAnalytics(adId: number): Promise<AdAnalytics | null> {
return await prisma.adAnalytics.findUnique({
where: { ad_id: adId },
});
}
/**
* Busca comportamento do usuário (comprador)
*/
export async function getUserBehavior(
userId: number,
startDate?: Date,
endDate?: Date
): Promise<UserBehavior | null> {
// Verificar se usuário existe
const user = await prisma.user.findUnique({
where: { user_id: userId },
select: { user_id: true },
});
if (!user) {
return null;
}
const dateFilter: Prisma.TelemetryEventWhereInput = {};
if (startDate && endDate) {
dateFilter.timestamp = {
gte: startDate,
lte: endDate,
};
}
// Buscar queries de busca
const searchEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "search",
...dateFilter,
},
select: {
metadata: true,
},
});
// Buscar filtros aplicados
const filterEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "filter",
...dateFilter,
},
select: {
metadata: true,
},
});
// Buscar itens visualizados
const viewEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "ad_view",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
metadata: true,
timestamp: true,
},
orderBy: { timestamp: "desc" },
});
// Buscar favoritos
const favoriteEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "favorite",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
},
distinct: ["ad_id"],
});
// Buscar compras
const purchaseEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "purchase",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
metadata: true,
timestamp: true,
},
orderBy: { timestamp: "desc" },
});
// Buscar horas ativas
const activeHoursRaw = await prisma.$queryRaw<
Array<{ hour: number; count: bigint }>
>`
SELECT
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
COUNT(*) as count
FROM telemetry_events
WHERE user_id = ${userId}
${startDate && endDate ? Prisma.sql`AND timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY hour
ORDER BY hour
`;
// Buscar item_id dos anúncios visualizados
const itemIds = await prisma.$queryRaw<Array<{ item_id: number }>>`
SELECT DISTINCT l.item_id
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.user_id = ${userId}
AND te.event_type = 'ad_view'
AND te.ad_id IS NOT NULL
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
`;
return {
user_id: userId,
search_queries: searchEvents
.map((r) => (r.metadata as any)?.query)
.filter((q): q is string => !!q),
filters_applied: filterEvents.reduce(
(acc, r) => ({ ...acc, ...(r.metadata as any) }),
{} as Record<string, any>
),
items_viewed: viewEvents.map((r) => ({
item_id: r.ad_id!,
duration: ((r.metadata as any)?.duration || 0) as number,
timestamp: r.timestamp,
})),
items_favorited: favoriteEvents.map((r) => r.ad_id!),
purchases: purchaseEvents.map((r) => ({
ad_id: r.ad_id!,
price: ((r.metadata as any)?.price || 0) as number,
timestamp: r.timestamp,
})),
active_hours: activeHoursRaw.map((r) => ({
hour: r.hour,
activity_count: Number(r.count),
})),
};
}
/**
* Busca métricas agregadas do marketplace
*/
export async function getMarketplaceStats(
startDate?: Date,
endDate?: Date
): Promise<MarketplaceStats> {
const dateFilter = startDate && endDate
? Prisma.sql`WHERE timestamp BETWEEN ${startDate} AND ${endDate}`
: Prisma.empty;
// Total de transações
const transactionsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::TEXT as count
FROM telemetry_events
WHERE event_type = 'purchase'
${dateFilter}
`;
const total_transactions = Number(transactionsResult[0]?.count || 0);
// Preço médio por categoria
const avgPriceResult = await prisma.$queryRaw<
Array<{ item_name: string; avg_price: string }>
>`
SELECT
l.item_name,
AVG(COALESCE(l.price_pix, l.price_tibia_coins, l.price_gold_coins))::TEXT as avg_price
FROM listings l
JOIN telemetry_events te ON te.ad_id = l.listing_id
WHERE te.event_type = 'purchase'
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY l.item_name
LIMIT 10
`;
// Taxa de conversão
const viewsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'ad_view'
${dateFilter}
`;
const purchasesResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'purchase'
${dateFilter}
`;
const viewsCount = Number(viewsResult[0]?.count || 0);
const purchasesCount = Number(purchasesResult[0]?.count || 0);
const conversion_rate = viewsCount > 0 ? (purchasesCount / viewsCount) * 100 : 0;
// Itens mais procurados
const mostSearchedResult = await prisma.$queryRaw<
Array<{ item_name: string; search_count: bigint }>
>`
SELECT
(metadata->>'query') as item_name,
COUNT(*) as search_count
FROM telemetry_events
WHERE event_type = 'search' AND (metadata->>'query') IS NOT NULL
${dateFilter}
GROUP BY (metadata->>'query')
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Itens mais vendidos
const mostSoldResult = await prisma.$queryRaw<
Array<{ item_id: number; item_name: string; sales_count: bigint }>
>`
SELECT
l.item_id,
l.item_name,
COUNT(*) as sales_count
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.event_type = 'purchase'
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY l.item_id, l.item_name
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Horários de pico
const peakHoursResult = await prisma.$queryRaw<
Array<{ hour: number; activity_count: bigint }>
>`
SELECT
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
COUNT(*) as activity_count
FROM telemetry_events
${dateFilter}
GROUP BY hour
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Tempo médio para venda
const avgTimeResult = await prisma.$queryRaw<
Array<{ avg_hours: string }>
>`
SELECT AVG(aa.time_to_sell)::TEXT as avg_hours
FROM ad_analytics aa
WHERE aa.time_to_sell IS NOT NULL
`;
return {
total_transactions,
avg_price_by_category: avgPriceResult.reduce(
(acc, r) => {
acc[r.item_name] = parseFloat(r.avg_price);
return acc;
},
{} as Record<string, number>
),
conversion_rate,
most_searched_items: mostSearchedResult.map((r) => ({
item_id: 0,
item_name: r.item_name || "",
search_count: Number(r.search_count),
})),
most_sold_items: mostSoldResult.map((r) => ({
item_id: r.item_id,
item_name: r.item_name,
sales_count: Number(r.sales_count),
})),
peak_hours: peakHoursResult.map((r) => ({
hour: r.hour,
activity_count: Number(r.activity_count),
})),
avg_time_to_sell: parseFloat(avgTimeResult[0]?.avg_hours || "0"),
};
}
/**
* Agrega eventos de telemetria em métricas de analytics
* Executar periodicamente (ex: a cada hora)
*/
export async function aggregateHourlyMetrics(): Promise<void> {
// Usar $queryRaw para queries complexas de agregação
// Por enquanto, manter a lógica original mas adaptada para Prisma
// Agregar impressões
await prisma.$executeRaw`
INSERT INTO ad_analytics (ad_id, impressions, last_updated)
SELECT
ad_id,
COUNT(*) as impressions,
CURRENT_TIMESTAMP
FROM telemetry_events
WHERE event_type = 'ad_view'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
ON CONFLICT (ad_id) DO UPDATE SET
impressions = ad_analytics.impressions + EXCLUDED.impressions,
last_updated = CURRENT_TIMESTAMP
`;
// Agregar cliques
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET clicks = clicks + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'ad_click'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Atualizar CTR
await prisma.$executeRaw`
UPDATE ad_analytics
SET ctr = CASE
WHEN impressions > 0 THEN (clicks::DECIMAL / impressions::DECIMAL) * 100
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE impressions > 0
`;
// Agregar duração média de visualização
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
avg_view_duration = COALESCE(
(
SELECT AVG((metadata->>'duration')::DECIMAL)
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND metadata->>'duration' IS NOT NULL
),
avg_view_duration
),
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`;
// Calcular bounce rate
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
bounce_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND (metadata->>'duration')::DECIMAL < 3
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE aa.impressions > 0
`;
// Agregar inquiries
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET inquiries = inquiries + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'inquiry'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Agregar favoritos
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET favorites = favorites + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'favorite'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Calcular taxa de conversão
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
conversion_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`;
// Calcular time_to_sell
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
time_to_sell = EXTRACT(EPOCH FROM (l.sold_at - l.created_at)) / 3600,
last_updated = CURRENT_TIMESTAMP
FROM listings l
WHERE aa.ad_id = l.listing_id
AND l.listing_status = 'sold'
AND l.sold_at IS NOT NULL
AND aa.time_to_sell IS NULL
AND l.sold_at >= NOW() - INTERVAL '1 hour'
`;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Queries de transactions usando Prisma
* Migrado de lib/db/queries/transactions.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export type TransactionStatus =
| "pending"
| "deposited"
| "item_delivered"
| "completed"
| "disputed"
| "cancelled"
| "refunded";
export interface Transaction {
transaction_id: number;
listing_id: number | null;
buyer_id: number;
seller_id: number;
item_name: string;
quantity: number;
server_name: string;
amount: number;
payment_method: "pix" | "tibia_coins" | "gold_coins" | null;
escrow_fee: number | null;
transaction_status: TransactionStatus;
created_at: Date;
deposited_at: Date | null;
delivered_at: Date | null;
completed_at: Date | null;
buyer_confirmed: boolean;
seller_confirmed: boolean;
buyer_notes: string | null;
seller_notes: string | null;
}
export interface CreateTransactionData {
listing_id?: number;
buyer_id: number;
seller_id: number;
item_name: string;
quantity: number;
server_name: string;
amount: number;
payment_method: "pix" | "tibia_coins" | "gold_coins";
escrow_fee?: number;
}
export interface UpdateTransactionStatusData {
transaction_status: TransactionStatus;
deposited_at?: Date;
delivered_at?: Date;
completed_at?: Date;
buyer_confirmed?: boolean;
seller_confirmed?: boolean;
buyer_notes?: string;
seller_notes?: string;
}
/**
* Cria uma nova transação
*/
export async function createTransaction(
data: CreateTransactionData
): Promise<Transaction> {
// Calcular escrow_fee se não fornecido (2% do valor)
const escrowFee = data.escrow_fee ?? Number((data.amount * 0.02).toFixed(2));
return await prisma.transaction.create({
data: {
listing_id: data.listing_id || null,
buyer_id: data.buyer_id,
seller_id: data.seller_id,
item_name: data.item_name,
quantity: data.quantity,
server_name: data.server_name,
amount: data.amount,
payment_method: data.payment_method,
escrow_fee: escrowFee,
},
});
}
/**
* Busca transação por ID
*/
export async function getTransactionById(
transactionId: number
): Promise<Transaction | null> {
return await prisma.transaction.findUnique({
where: { transaction_id: transactionId },
});
}
/**
* Atualiza status da transação
*/
export async function updateTransactionStatus(
transactionId: number,
data: UpdateTransactionStatusData
): Promise<Transaction | null> {
const updateData: Prisma.TransactionUpdateInput = {
transaction_status: data.transaction_status,
};
if (data.deposited_at !== undefined) updateData.deposited_at = data.deposited_at;
if (data.delivered_at !== undefined) updateData.delivered_at = data.delivered_at;
if (data.completed_at !== undefined) updateData.completed_at = data.completed_at;
if (data.buyer_confirmed !== undefined) updateData.buyer_confirmed = data.buyer_confirmed;
if (data.seller_confirmed !== undefined) updateData.seller_confirmed = data.seller_confirmed;
if (data.buyer_notes !== undefined) updateData.buyer_notes = data.buyer_notes;
if (data.seller_notes !== undefined) updateData.seller_notes = data.seller_notes;
return await prisma.transaction.update({
where: { transaction_id: transactionId },
data: updateData,
});
}
/**
* Busca transações de um usuário (comprador ou vendedor)
*/
export async function getTransactionsByUser(
userId: number,
filters: {
role?: "buyer" | "seller" | "both";
status?: TransactionStatus;
page?: number;
pageSize?: number;
} = {}
): Promise<{ transactions: Transaction[]; total: number }> {
const { role = "both", status, page = 1, pageSize = 20 } = filters;
const where: Prisma.TransactionWhereInput = {};
if (role === "buyer") {
where.buyer_id = userId;
} else if (role === "seller") {
where.seller_id = userId;
} else {
where.OR = [
{ buyer_id: userId },
{ seller_id: userId },
];
}
if (status) {
where.transaction_status = status;
}
// Contar total
const total = await prisma.transaction.count({ where });
// Buscar transações
const transactions = await prisma.transaction.findMany({
where,
orderBy: { created_at: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
transactions,
total,
};
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Queries de usuários usando Prisma
* Migrado de lib/db/queries/users.ts
*/
import { prisma } from "../app";
import type { User } from "@prisma/client";
export interface CreateUserData {
username: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
phone?: string;
cpf?: string;
}
export interface UpdateUserData {
first_name?: string;
last_name?: string;
phone?: string;
whatsapp_notifications?: boolean;
}
/**
* Cria um novo usuário
*/
export async function createUser(
data: CreateUserData
): Promise<{ userId: number; username: string; email: string }> {
const user = await prisma.user.create({
data: {
username: data.username,
email: data.email,
password_hash: data.password_hash,
first_name: data.first_name,
last_name: data.last_name,
phone: data.phone || null,
cpf: data.cpf || null,
},
select: {
user_id: true,
username: true,
email: true,
},
});
return {
userId: user.user_id,
username: user.username,
email: user.email,
};
}
/**
* Busca usuário por email (inclui password_hash para login)
*/
export async function getUserByEmail(email: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { email },
});
}
/**
* Busca usuário por username
*/
export async function getUserByUsername(
username: string
): Promise<User | null> {
return await prisma.user.findUnique({
where: { username },
});
}
/**
* Busca usuário por ID (sem password_hash)
*/
export async function getUserById(userId: number): Promise<User | null> {
return await prisma.user.findUnique({
where: { user_id: userId },
select: {
user_id: true,
username: true,
email: true,
first_name: true,
last_name: true,
phone: true,
cpf: true,
account_level: true,
has_donated: true,
whatsapp_notifications: true,
created_at: true,
updated_at: true,
last_login: true,
is_active: true,
is_verified: true,
},
});
}
/**
* Busca usuário por CPF (verifica duplicidade)
*/
export async function getUserByCpf(cpf: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { cpf },
select: {
user_id: true,
username: true,
email: true,
},
});
}
/**
* Atualiza dados do usuário
*/
export async function updateUser(
userId: number,
data: UpdateUserData
): Promise<User> {
const updateData: any = {};
if (data.first_name !== undefined) updateData.first_name = data.first_name;
if (data.last_name !== undefined) updateData.last_name = data.last_name;
if (data.phone !== undefined) updateData.phone = data.phone || null;
if (data.whatsapp_notifications !== undefined)
updateData.whatsapp_notifications = data.whatsapp_notifications;
const user = await prisma.user.update({
where: { user_id: userId },
data: updateData,
select: {
user_id: true,
username: true,
email: true,
first_name: true,
last_name: true,
phone: true,
cpf: true,
account_level: true,
has_donated: true,
whatsapp_notifications: true,
created_at: true,
updated_at: true,
last_login: true,
is_active: true,
is_verified: true,
},
});
return user;
}
/**
* Atualiza timestamp de último login
*/
export async function updateLastLogin(userId: number): Promise<void> {
await prisma.user.update({
where: { user_id: userId },
data: { last_login: new Date() },
});
}