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

+1 -1
View File
@@ -15,7 +15,7 @@ import {
getUserByEmail,
getUserByUsername,
updateLastLogin,
} from "@/lib/db/queries/users";
} from "@/lib/prisma/queries/users";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
+2 -2
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserById } from "@/lib/db/queries/users";
import { getUserCharacters } from "@/lib/db/queries/characters";
import { getUserById } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters";
export async function GET(request: NextRequest) {
try {
+67 -74
View File
@@ -14,14 +14,15 @@ import {
import {
getUserByEmail,
getUserByUsername,
} from "@/lib/db/queries/users";
} from "@/lib/prisma/queries/users";
import {
isServerActive,
incrementUsersForServers,
} from "@/lib/db/queries/servers";
} from "@/lib/prisma/queries/servers";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
import { getPool } from "@/lib/db";
import { createUser } from "@/lib/prisma/queries/users";
import { createCharacter } from "@/lib/prisma/queries/characters";
export async function POST(request: NextRequest) {
try {
@@ -110,90 +111,82 @@ export async function POST(request: NextRequest) {
// Hash da senha
const passwordHash = await hashPassword(validatedData.password);
// Criar usuário e personagens em transação atômica
const pool = getPool();
const client = await pool.connect();
// Criar usuário e personagens em transação atômica usando Prisma
const { prisma } = await import("@/lib/prisma/app");
let newUser: { userId: number; username: string; email: string };
const createdCharacters: Array<{ characterName: string; serverName: string }> = [];
try {
await client.query("BEGIN");
// Usar transação Prisma
await prisma.$transaction(async (tx) => {
// Criar usuário
const user = await tx.user.create({
data: {
username: validatedData.username,
email: validatedData.email,
password_hash: passwordHash,
first_name: validatedData.firstName,
last_name: validatedData.lastName,
phone: validatedData.phone || null,
cpf: null, // CPF não é mais obrigatório
},
select: {
user_id: true,
username: true,
email: true,
},
});
// Criar usuário
const userResult = await client.query<{
user_id: number;
username: string;
email: string;
}>(
`
INSERT INTO users (username, email, password_hash, first_name, last_name, phone, cpf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING user_id, username, email
`,
[
validatedData.username,
validatedData.email,
passwordHash,
validatedData.firstName,
validatedData.lastName,
validatedData.phone || null,
null, // CPF não é mais obrigatório
]
);
newUser = {
userId: user.user_id,
username: user.username,
email: user.email,
};
newUser = {
userId: userResult.rows[0].user_id,
username: userResult.rows[0].username,
email: userResult.rows[0].email,
};
// Criar personagens (primeiro é main por padrão)
for (let i = 0; i < validatedData.servers.length; i++) {
const serverData = validatedData.servers[i];
const isMain = i === 0 || serverData.isMain === true;
// Criar personagens (primeiro é main por padrão)
for (let i = 0; i < validatedData.servers.length; i++) {
const serverData = validatedData.servers[i];
const isMain = i === 0 || serverData.isMain === true;
// Se este personagem será main e não é o primeiro, desmarcar outros
if (isMain && i > 0) {
await tx.userCharacter.updateMany({
where: { user_id: newUser.userId },
data: { is_main: false },
});
}
// Se este personagem será main e não é o primeiro, desmarcar outros
if (isMain && i > 0) {
await client.query(
`
UPDATE user_characters
SET is_main = FALSE
WHERE user_id = $1
`,
[newUser.userId]
);
// Inserir personagem
const character = await tx.userCharacter.create({
data: {
user_id: newUser.userId,
character_name: serverData.characterName,
server_name: serverData.serverName,
is_main: isMain,
},
});
createdCharacters.push({
characterName: character.character_name,
serverName: character.server_name,
});
}
// Inserir personagem
const charResult = await client.query<{
character_id: number;
character_name: string;
server_name: string;
}>(
`
INSERT INTO user_characters (user_id, character_name, server_name, is_main)
VALUES ($1, $2, $3, $4)
RETURNING character_id, character_name, server_name
`,
[newUser.userId, serverData.characterName, serverData.serverName, isMain]
);
createdCharacters.push({
characterName: charResult.rows[0].character_name,
serverName: charResult.rows[0].server_name,
// Incrementar total_users dos servidores (dentro da transação)
await tx.server.updateMany({
where: {
server_name: { in: uniqueServerNames },
},
data: {
total_users: { increment: 1 },
last_activity_at: new Date(),
},
});
}
// Incrementar total_users dos servidores
await incrementUsersForServers(uniqueServerNames);
await client.query("COMMIT");
});
} catch (error) {
await client.query("ROLLBACK");
// Prisma já faz rollback automático em caso de erro
throw error;
} finally {
client.release();
}
// Gerar tokens
+95 -2
View File
@@ -1,14 +1,105 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { getItemById } from "@/lib/items-db";
import { getItemByIdUnified } from "@/lib/db/queries/items";
import { buildItemImagePath } from "@/lib/item-images";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const relPath = searchParams.get("path") || "";
const itemId = searchParams.get("item");
const itemName = searchParams.get("name"); // Nome do item para validação (opcional)
let imagePath: string | null = null;
// Se foi fornecido um item ID, buscar o item no banco
if (itemId) {
try {
const itemIdNum = parseInt(itemId, 10);
if (!isNaN(itemIdNum)) {
// Tentar primeiro buscar na tabela unificada PostgreSQL
// Se a tabela não existir, retornará null silenciosamente
let item: any = null;
item = await getItemByIdUnified(itemIdNum);
// Se encontrou na tabela unificada, validar o nome se fornecido
if (item && itemName && item.item_name) {
if (item.item_name.toLowerCase() !== itemName.toLowerCase()) {
// Nome não bate, ignorar este resultado
item = null;
}
}
// Se não encontrou na tabela unificada, tentar SQLite legado
// Passar itemName para buscar por ID E NOME (evita ambiguidade)
if (!item) {
item = getItemById(itemIdNum, itemName || undefined);
}
if (item) {
// PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback)
const imageLocalPath = item.image_local_path || "";
const imageUrl = item.image_url || "";
const image = item.image || "";
// Tentar primeiro a imagem local
if (imageLocalPath) {
const normalizedPath = imageLocalPath.replace(/\\/g, "/");
const category = (item.categoria || item.category || "other").toLowerCase();
// Se já for um caminho completo (começa com data/), usar diretamente
if (normalizedPath.startsWith("data/")) {
imagePath = normalizedPath;
} else {
// Construir o caminho usando a função helper
imagePath = buildItemImagePath(category, normalizedPath);
}
// Verificar se o arquivo existe antes de usar
if (imagePath) {
const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), imagePath);
if (resolved.startsWith(baseDir)) {
try {
await fs.promises.access(resolved);
// Arquivo existe, usar este caminho
} catch {
// Arquivo não existe, tentar fallback externo
imagePath = null;
}
}
}
}
// Se não encontrou imagem local ou não existe, usar URL externa como fallback
if (!imagePath && (imageUrl || image)) {
const externalUrl = imageUrl || image;
if (externalUrl.startsWith("http://") || externalUrl.startsWith("https://")) {
return Response.redirect(externalUrl, 302);
}
}
}
}
} catch (error) {
console.error("[api/images] Erro ao buscar item:", error);
}
} else if (relPath) {
// Se foi fornecido um path, usar diretamente
imagePath = relPath;
}
if (!imagePath) {
// Se não foi possível construir o caminho, retornar 404 ao invés de 400
// para evitar erros visíveis no console do navegador
return new Response("Not Found", { status: 404 });
}
// Permitir apenas caminhos sob data/images para segurança
const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), relPath);
const resolved = path.join(process.cwd(), imagePath);
if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 });
}
@@ -27,7 +118,9 @@ export async function GET(req: NextRequest) {
? "image/jpeg"
: "application/octet-stream";
return new Response(file, { status: 200, headers: { "Content-Type": contentType } });
} catch {
} catch (error) {
console.error("[api/images] Erro ao ler arquivo:", error);
// Retornar 404 silenciosamente para não gerar erros no console
return new Response("Not Found", { status: 404 });
}
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
/**
* POST /api/listings/:id/activate
* Reativa um anúncio pausado (apenas dono)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
);
}
// Verificar se o anúncio existe e pertence ao usuário
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
);
}
if (listing.seller_id !== auth.userId) {
return NextResponse.json(
{
success: false,
error: "Você não tem permissão para reativar este anúncio",
},
{ status: 403 }
);
}
if (listing.listing_status !== "cancelled") {
return NextResponse.json(
{
success: false,
error: "Apenas anúncios pausados podem ser reativados",
},
{ status: 400 }
);
}
// Reativar anúncio
const updated = await updateListing(listingId, {
listing_status: "active",
});
if (!updated) {
return NextResponse.json(
{
success: false,
error: "Erro ao reativar anúncio",
},
{ status: 500 }
);
}
secureLogger.info("✅ Anúncio reativado", {
listingId,
userId: auth.userId,
});
return NextResponse.json(
{
success: true,
listing: updated,
message: "Anúncio reativado com sucesso",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao reativar anúncio", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao reativar anúncio",
},
{ status: 500 }
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { placeBid } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
/**
* POST /api/listings/[id]/bid
* Coloca um lance em um leilão
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
const body = await request.json();
const { amount, payment_method } = body;
if (!amount || typeof amount !== "number" || amount <= 0) {
return NextResponse.json(
{ success: false, error: "Valor do lance é obrigatório e deve ser maior que zero" },
{ status: 400 }
);
}
if (!payment_method || !["pix", "tc", "gp"].includes(payment_method)) {
return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" },
{ status: 400 }
);
}
// Colocar lance
const result = await placeBid(listingId, auth.userId, amount, payment_method);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
// Criar notificação para o vendedor
await createNotification({
user_id: result.listing!.seller_id,
notification_type: "bid",
title: "Novo lance!",
message: `Novo lance de R$ ${amount.toFixed(2)} no item "${result.listing!.item_name}"`,
related_id: listingId,
send_whatsapp: false,
});
// Se havia um lanceador anterior, notificar que foi superado
if (result.previousBidderId) {
await createNotification({
user_id: result.previousBidderId,
notification_type: "bid",
title: "Lance superado",
message: `Seu lance foi superado no item "${result.listing!.item_name}"`,
related_id: listingId,
send_whatsapp: false,
});
}
// Registrar telemetria
const { trackEvent } = await import("@/lib/prisma/queries/telemetry");
await trackEvent({
event_type: "bid_placed",
ad_id: listingId,
user_id: auth.userId,
metadata: {
bid_amount: amount,
payment_method: payment_method,
previous_bid: result.listing!.current_bid_amount ? Number(result.listing!.current_bid_amount) : null,
min_increment: result.listing!.min_bid_increment ? Number(result.listing!.min_bid_increment) : null,
},
});
secureLogger.info("✅ Lance realizado com sucesso", {
userId: auth.userId,
listingId,
amount,
paymentMethod: payment_method,
});
return NextResponse.json(
{
success: true,
listing: result.listing,
message: "Lance realizado com sucesso",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao processar lance", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao processar lance" },
{ status: 500 }
);
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { prisma } from "@/lib/prisma/app";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
/**
* GET /api/listings/[id]/bids
* Lista lances de um anúncio
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
// Buscar listing para verificar se é leilão
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
);
}
if (!listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este anúncio não é um leilão" },
{ status: 400 }
);
}
// Buscar parâmetros de filtro
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "50", 10);
// Buscar lances
const bids = await prisma.bid.findMany({
where: {
listing_id: listingId,
is_active: true,
},
include: {
bidder: {
select: {
user_id: true,
username: true,
},
},
},
orderBy: {
created_at: "desc",
},
take: limit,
});
// Formatar resposta
const formattedBids = bids.map((bid) => ({
bid_id: bid.bid_id,
bidder_id: bid.bidder_id,
bidder_name: bid.bidder.username,
bid_amount: Number(bid.bid_amount),
bid_type: bid.bid_type,
created_at: bid.created_at,
}));
// Registrar telemetria
await trackEvent({
event_type: "bid_viewed",
ad_id: listingId,
user_id: authResult.userId,
metadata: {
bids_count: formattedBids.length,
},
});
return NextResponse.json(
{
success: true,
bids: formattedBids,
total: formattedBids.length,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar histórico de lances", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao buscar histórico de lances" },
{ status: 500 }
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites";
/**
* POST /api/listings/:id/favorite
* Favoritar ou desfavoritar um anúncio
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
);
}
// Verificar se o anúncio existe
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
);
}
// Toggle favorite
const favorited = await toggleFavorite(auth.userId, listingId);
secureLogger.info(`✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`, {
listingId,
userId: auth.userId,
});
return NextResponse.json(
{
success: true,
favorited,
message: favorited ? "Anúncio favoritado" : "Anúncio desfavoritado",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao favoritar/desfavoritar anúncio", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{
success: false,
error: "Erro ao favoritar/desfavoritar anúncio",
},
{ status: 500 }
);
}
}
/**
* GET /api/listings/:id/favorite
* Verifica se o anúncio está favoritado pelo usuário
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
);
}
const favorited = await isFavorite(auth.userId, listingId);
return NextResponse.json(
{
success: true,
favorited,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao verificar favorito", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao verificar favorito",
},
{ status: 500 }
);
}
}
@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import {
getPurchaseIntentionById,
updatePurchaseIntentionStatus,
} from "@/lib/prisma/queries/purchase-intentions";
import { createNotification } from "@/lib/prisma/queries/notifications";
/**
* GET /api/listings/[id]/intentions/[intentionId]
* Busca uma intenção específica
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string; intentionId: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const intentionId = parseInt(params.intentionId, 10);
if (isNaN(intentionId)) {
return NextResponse.json(
{ success: false, error: "ID de intenção inválido" },
{ status: 400 }
);
}
const intention = await getPurchaseIntentionById(intentionId);
if (!intention) {
return NextResponse.json(
{ success: false, error: "Intenção não encontrada" },
{ status: 404 }
);
}
// Verificar permissões: vendedor ou comprador
if (intention.listing?.seller_id !== authResult.userId && intention.buyer_id !== authResult.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para ver esta intenção" },
{ status: 403 }
);
}
return NextResponse.json(
{
success: true,
intention,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar intenção", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao buscar intenção" },
{ status: 500 }
);
}
}
/**
* PATCH /api/listings/[id]/intentions/[intentionId]
* Atualiza o status de uma intenção (aceitar/rejeitar)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string; intentionId: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const intentionId = parseInt(params.intentionId, 10);
if (isNaN(intentionId)) {
return NextResponse.json(
{ success: false, error: "ID de intenção inválido" },
{ status: 400 }
);
}
const body = await request.json();
const { status, seller_response } = body;
if (!status || !["accepted", "rejected"].includes(status)) {
return NextResponse.json(
{ success: false, error: "Status inválido. Use 'accepted' ou 'rejected'" },
{ status: 400 }
);
}
// Buscar intenção
const intention = await getPurchaseIntentionById(intentionId);
if (!intention) {
return NextResponse.json(
{ success: false, error: "Intenção não encontrada" },
{ status: 404 }
);
}
// Verificar se o usuário é o vendedor
if (intention.listing?.seller_id !== authResult.userId) {
return NextResponse.json(
{ success: false, error: "Apenas o vendedor pode aceitar ou rejeitar intenções" },
{ status: 403 }
);
}
// Verificar se a intenção ainda está pendente
if (intention.status !== "pending") {
return NextResponse.json(
{ success: false, error: `Esta intenção já foi ${intention.status}` },
{ status: 400 }
);
}
// Verificar se o anúncio ainda está disponível (ativo ou em escrow)
const listing = await getListingById(intention.listing_id);
if (!listing || (listing.listing_status !== "active" && listing.listing_status !== "in_escrow")) {
return NextResponse.json(
{ success: false, error: "Anúncio não está mais disponível" },
{ status: 400 }
);
}
// Atualizar status da intenção (não altera status do listing)
const updatedIntention = await updatePurchaseIntentionStatus(
intentionId,
status,
seller_response
);
// Criar notificação para o comprador
await createNotification({
user_id: intention.buyer_id,
notification_type: status === "accepted" ? "purchase" : "system",
title: status === "accepted" ? "Intenção aceita!" : "Intenção rejeitada",
message: status === "accepted"
? `O vendedor aceitou sua intenção de compra do item "${intention.listing?.item_name}"`
: `O vendedor rejeitou sua intenção de compra do item "${intention.listing?.item_name}"`,
related_id: intention.listing_id,
send_whatsapp: false,
});
secureLogger.info(`✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`, {
userId: authResult.userId,
intentionId,
listingId: intention.listing_id,
buyerId: intention.buyer_id,
});
return NextResponse.json(
{
success: true,
intention: updatedIntention,
message: `Intenção ${status === "accepted" ? "aceita" : "rejeitada"} com sucesso`,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao atualizar intenção", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao atualizar intenção" },
{ status: 500 }
);
}
}
+242
View File
@@ -0,0 +1,242 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import {
createPurchaseIntention,
getPurchaseIntentionsByListing,
hasPendingIntention,
} from "@/lib/prisma/queries/purchase-intentions";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
/**
* POST /api/listings/[id]/intentions
* Cria uma nova intenção de compra
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
const body = await request.json();
const { payment_method, buyer_message } = body;
if (!payment_method || !["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)) {
return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" },
{ status: 400 }
);
}
// Buscar listing
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
);
}
// Validar se não é o vendedor
if (listing.seller_id === auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não pode criar uma intenção de compra para seu próprio anúncio" },
{ status: 403 }
);
}
// Validar se está ativo
if (listing.listing_status !== "active") {
return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" },
{ status: 400 }
);
}
// Validar se é leilão (leilões não podem ter intenções, apenas lances)
if (listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este item está em leilão. Use a funcionalidade de lance." },
{ status: 400 }
);
}
// Validar método de pagamento disponível
const availableMethods: string[] = [];
if (listing.price_pix) availableMethods.push("pix");
if (listing.price_tibia_coins) availableMethods.push("tibia_coins");
if (listing.price_gold_coins) availableMethods.push("gold_coins");
if (listing.trade_item_id) availableMethods.push("trade");
if (!availableMethods.includes(payment_method)) {
return NextResponse.json(
{
success: false,
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods,
},
{ status: 400 }
);
}
// Verificar se já existe uma intenção pendente
const alreadyHasPending = await hasPendingIntention(listingId, auth.userId);
if (alreadyHasPending) {
return NextResponse.json(
{ success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" },
{ status: 400 }
);
}
// Criar intenção de compra
const intention = await createPurchaseIntention({
listing_id: listingId,
buyer_id: auth.userId,
payment_method: payment_method as any,
buyer_message: buyer_message || undefined,
});
// Criar notificação para o vendedor
await createNotification({
user_id: listing.seller_id,
notification_type: "purchase",
title: "Nova intenção de compra!",
message: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${payment_method.toUpperCase()}`,
related_id: listingId,
send_whatsapp: false,
});
// Registrar telemetria
await trackEvent({
event_type: "purchase_intention_created",
ad_id: listingId,
user_id: auth.userId,
metadata: {
payment_method,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || null,
listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
},
});
secureLogger.info("✅ Intenção de compra criada com sucesso", {
userId: auth.userId,
listingId,
intentionId: intention.intention_id,
paymentMethod: payment_method,
});
return NextResponse.json(
{
success: true,
intention,
message: "Intenção de compra criada com sucesso. O vendedor será notificado.",
},
{ status: 201 }
);
} catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" },
{ status: 500 }
);
}
}
/**
* GET /api/listings/[id]/intentions
* Lista intenções de compra de um anúncio (apenas vendedor)
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
// Buscar listing para verificar se o usuário é o vendedor
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
);
}
// Apenas o vendedor pode ver as intenções
if (listing.seller_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para ver as intenções deste anúncio" },
{ status: 403 }
);
}
// Buscar parâmetros de filtro
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as any;
const limit = parseInt(searchParams.get("limit") || "50", 10);
// Buscar intenções
const intentions = await getPurchaseIntentionsByListing(listingId, {
status,
limit,
});
// Registrar telemetria
await trackEvent({
event_type: "purchase_intention_viewed",
ad_id: listingId,
user_id: auth.userId,
metadata: {
intentions_count: intentions.length,
},
});
return NextResponse.json(
{
success: true,
intentions,
count: intentions.length,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar intenções de compra", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao buscar intenções de compra" },
{ status: 500 }
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
/**
* POST /api/listings/:id/mark-sold
* Marca um anúncio como vendido (apenas dono)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
);
}
// Verificar se o anúncio existe e pertence ao usuário
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
);
}
if (listing.seller_id !== auth.userId) {
return NextResponse.json(
{
success: false,
error: "Você não tem permissão para marcar este anúncio como vendido",
},
{ status: 403 }
);
}
// Verificar se o anúncio pode ser marcado como vendido
if (listing.listing_status !== "active" && listing.listing_status !== "in_escrow") {
return NextResponse.json(
{
success: false,
error: "Apenas anúncios ativos ou em escrow podem ser marcados como vendidos",
},
{ status: 400 }
);
}
// Marcar como vendido e definir sold_at usando Prisma diretamente
const { prisma } = await import("@/lib/prisma/app");
const updatedListing = await prisma.listing.update({
where: { listing_id: listingId },
data: {
listing_status: "sold",
sold_at: new Date(),
},
});
// Buscar character name para retornar dados completos
const character = await prisma.userCharacter.findFirst({
where: {
user_id: updatedListing.seller_id,
server_name: updatedListing.server_name,
is_main: true,
},
});
// Converter campos para formato esperado
const updated = {
...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,
};
if (!updated) {
return NextResponse.json(
{
success: false,
error: "Erro ao marcar anúncio como vendido",
},
{ status: 500 }
);
}
secureLogger.info("✅ Anúncio marcado como vendido", {
listingId,
userId: auth.userId,
});
return NextResponse.json(
{
success: true,
listing: updated,
message: "Anúncio marcado como vendido com sucesso",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar anúncio como vendido", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao marcar anúncio como vendido",
},
{ status: 500 }
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
/**
* POST /api/listings/:id/pause
* Pausa um anúncio (apenas dono)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
);
}
// Verificar se o anúncio existe e pertence ao usuário
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
);
}
if (listing.seller_id !== auth.userId) {
return NextResponse.json(
{
success: false,
error: "Você não tem permissão para pausar este anúncio",
},
{ status: 403 }
);
}
if (listing.listing_status !== "active") {
return NextResponse.json(
{
success: false,
error: "Apenas anúncios ativos podem ser pausados",
},
{ status: 400 }
);
}
// Pausar anúncio (usando status 'cancelled' temporariamente, ou podemos criar um novo status 'paused')
// Por enquanto, vamos usar 'cancelled' mas manter os dados para poder reativar
const updated = await updateListing(listingId, {
listing_status: "cancelled",
});
if (!updated) {
return NextResponse.json(
{
success: false,
error: "Erro ao pausar anúncio",
},
{ status: 500 }
);
}
secureLogger.info("✅ Anúncio pausado", {
listingId,
userId: auth.userId,
});
return NextResponse.json(
{
success: true,
listing: updated,
message: "Anúncio pausado com sucesso",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao pausar anúncio", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao pausar anúncio",
},
{ status: 500 }
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import {
createPurchaseIntention,
hasPendingIntention,
} from "@/lib/prisma/queries/purchase-intentions";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
/**
* POST /api/listings/[id]/purchase
* Cria uma intenção de compra para um item anunciado
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
const body = await request.json();
const { payment_method, buyer_message } = body; // 'pix' | 'tc' | 'gp' | 'trade'
if (!payment_method) {
return NextResponse.json(
{ success: false, error: "Método de pagamento é obrigatório" },
{ status: 400 }
);
}
// Buscar listing
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
);
}
// Validar se não é o vendedor
if (listing.seller_id === auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não pode comprar seu próprio anúncio" },
{ status: 403 }
);
}
// Validar se está ativo
if (listing.listing_status !== "active") {
return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" },
{ status: 400 }
);
}
// Validar se é leilão (leilões não podem ser comprados diretamente, apenas com lances)
if (listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este item está em leilão. Use a funcionalidade de lance." },
{ status: 400 }
);
}
// Validar método de pagamento disponível
const availableMethods: string[] = [];
if (listing.price_pix) availableMethods.push("pix");
if (listing.price_tibia_coins) availableMethods.push("tibia_coins");
if (listing.price_gold_coins) availableMethods.push("gold_coins");
if (listing.trade_item_id) availableMethods.push("trade");
// Normalizar método de pagamento
let normalizedMethod = payment_method;
if (payment_method === "tc") normalizedMethod = "tibia_coins";
if (payment_method === "gp") normalizedMethod = "gold_coins";
if (!availableMethods.includes(normalizedMethod)) {
return NextResponse.json(
{
success: false,
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods,
},
{ status: 400 }
);
}
// Verificar se já existe uma intenção pendente
const alreadyHasPending = await hasPendingIntention(listingId, auth.userId);
if (alreadyHasPending) {
return NextResponse.json(
{ success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" },
{ status: 400 }
);
}
// Criar intenção de compra ao invés de mudar status diretamente
const intention = await createPurchaseIntention({
listing_id: listingId,
buyer_id: auth.userId,
payment_method: normalizedMethod as any,
buyer_message: buyer_message || undefined,
});
// Criar notificação para o vendedor
await createNotification({
user_id: listing.seller_id,
notification_type: "purchase",
title: "Nova intenção de compra!",
message: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${normalizedMethod.toUpperCase()}`,
related_id: listingId,
send_whatsapp: false,
});
// Registrar telemetria
await trackEvent({
event_type: "purchase_intention_created",
ad_id: listingId,
user_id: auth.userId,
metadata: {
payment_method: normalizedMethod,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || null,
listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
},
});
secureLogger.info("✅ Intenção de compra criada com sucesso", {
userId: auth.userId,
listingId,
intentionId: intention.intention_id,
paymentMethod: normalizedMethod,
});
return NextResponse.json(
{
success: true,
intention,
message: "Intenção de compra criada com sucesso. O vendedor será notificado.",
},
{ status: 201 }
);
} catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {
getListingById,
updateListing,
deleteListing,
} from "@/lib/db/queries/listings";
} from "@/lib/prisma/queries/listings";
import { updateListingSchema } from "@/lib/validation-schemas";
/**
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { getListingById } from "@/lib/prisma/queries/listings";
import { getMainCharacterByUserId } from "@/lib/prisma/queries/characters";
/**
* GET /api/listings/[id]/seller
* Retorna informações do vendedor (nome do personagem)
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const listingId = parseInt(params.id, 10);
if (isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
);
}
const listing = await getListingById(listingId);
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
);
}
// Buscar personagem principal do vendedor no servidor do listing
const character = await getMainCharacterByUserId(listing.seller_id, listing.server_name);
return NextResponse.json(
{
success: true,
seller: {
user_id: listing.seller_id,
character_name: character?.character_name || null,
server_name: listing.server_name,
},
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar informações do vendedor", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao buscar informações do vendedor" },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { incrementViews } from "@/lib/db/queries/listings";
import { incrementViews } from "@/lib/prisma/queries/listings";
/**
* POST /api/listings/[id]/views
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getListings } from "@/lib/prisma/queries/listings";
/**
* GET /api/listings/my
* Lista anúncios do usuário autenticado
*/
export async function GET(request: NextRequest) {
try {
// Autenticação obrigatória
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { searchParams } = new URL(request.url);
const filters = {
seller_id: auth.userId,
status: searchParams.get("status") as
| "active"
| "sold"
| "cancelled"
| "in_escrow"
| "expired"
| undefined,
page: searchParams.get("page")
? parseInt(searchParams.get("page")!, 10)
: 1,
pageSize: searchParams.get("pageSize")
? parseInt(searchParams.get("pageSize")!, 10)
: 20,
sortBy: (searchParams.get("sortBy") ||
"created_at") as
| "created_at"
| "price_pix"
| "price_tibia_coins"
| "price_gold_coins"
| "views_count",
sortOrder: (searchParams.get("sortOrder") || "DESC") as "ASC" | "DESC",
};
const listingsData = await getListings(filters);
return NextResponse.json(
{
success: true,
listings: listingsData.listings,
pagination: {
page: filters.page,
pageSize: filters.pageSize,
total: listingsData.total,
totalPages: Math.ceil(listingsData.total / filters.pageSize),
},
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar anúncios do usuário", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{
success: false,
error: "Erro ao buscar anúncios",
},
{ status: 500 }
);
}
}
+6 -2
View File
@@ -11,8 +11,8 @@ import {
getListings,
createListing,
getActiveListings,
} from "@/lib/db/queries/listings";
import { isServerActive } from "@/lib/db/queries/servers";
} from "@/lib/prisma/queries/listings";
import { isServerActive } from "@/lib/prisma/queries/servers";
/**
* GET /api/listings
@@ -162,6 +162,7 @@ export async function POST(request: NextRequest) {
const listing = await createListing({
seller_id: auth.userId,
item_id: validatedData.item_id,
item_id_unified: validatedData.item_id_unified,
item_name: validatedData.item_name,
quantity: validatedData.quantity,
server_name: validatedData.server_name,
@@ -171,6 +172,9 @@ export async function POST(request: NextRequest) {
is_auction: validatedData.is_auction,
auction_ends_at: validatedData.auction_ends_at ?? undefined,
description: validatedData.description ?? undefined,
trade_item_id: validatedData.trade_item_id ?? undefined,
trade_item_name: validatedData.trade_item_name ?? undefined,
trade_item_quantity: validatedData.trade_item_quantity ?? undefined,
});
secureLogger.info("✅ Anúncio criado com sucesso", {
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { markAsRead } from "@/lib/prisma/queries/notifications";
/**
* POST /api/notifications/[id]/read
* Marca uma notificação específica como lida
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const notificationId = parseInt(params.id, 10);
if (isNaN(notificationId)) {
return NextResponse.json(
{ success: false, error: "ID de notificação inválido" },
{ status: 400 }
);
}
const success = await markAsRead(notificationId, auth.userId);
if (!success) {
return NextResponse.json(
{ success: false, error: "Notificação não encontrada ou já está lida" },
{ status: 404 }
);
}
return NextResponse.json(
{
success: true,
message: "Notificação marcada como lida",
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar notificação como lida", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{ success: false, error: "Erro ao marcar notificação como lida" },
{ status: 500 }
);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import {
getUserNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
} from "@/lib/prisma/queries/notifications";
/**
* GET /api/notifications
* Lista notificações do usuário autenticado
*/
export async function GET(request: NextRequest) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const { searchParams } = new URL(request.url);
const isRead = searchParams.get("is_read");
const limit = searchParams.get("limit")
? parseInt(searchParams.get("limit")!, 10)
: 50;
const notifications = await getUserNotifications(auth.userId, {
is_read: isRead === "true" ? true : isRead === "false" ? false : undefined,
limit,
});
const unreadCount = await getUnreadCount(auth.userId);
return NextResponse.json(
{
success: true,
notifications,
unread_count: unreadCount,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar notificações", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ success: false, error: "Erro ao buscar notificações" },
{ status: 500 }
);
}
}
/**
* POST /api/notifications/read-all
* Marca todas as notificações como lidas
*/
export async function POST(request: NextRequest) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");
if (action === "read-all") {
const count = await markAllAsRead(auth.userId);
return NextResponse.json(
{
success: true,
message: `${count} notificações marcadas como lidas`,
count,
},
{ status: 200 }
);
}
return NextResponse.json(
{ success: false, error: "Ação inválida" },
{ status: 400 }
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar notificações como lidas", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{ success: false, error: "Erro ao marcar notificações como lidas" },
{ status: 500 }
);
}
}
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getUnreadCount } from "@/lib/prisma/queries/notifications";
/**
* GET /api/notifications/unread-count
* Retorna a contagem de notificações não lidas do usuário
*/
export async function GET(request: NextRequest) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const count = await getUnreadCount(authResult.userId);
return NextResponse.json(
{
success: true,
count,
},
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar contagem de notificações" },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getActiveServers } from "@/lib/db/queries/servers";
import { getActiveServers } from "@/lib/prisma/queries/servers";
import { secureLogger } from "@/lib/secure-logger";
/**
+240
View File
@@ -0,0 +1,240 @@
import { NextRequest, NextResponse } from "next/server";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import {
telemetryEventSchema,
telemetryBatchSchema,
} from "@/lib/validation-schemas";
import { optionalAuth } from "@/lib/auth/middleware";
import {
trackEvent,
trackEventsBatch,
getAdAnalytics,
getMarketplaceStats,
} from "@/lib/prisma/queries/telemetry";
/**
* POST /api/telemetry/event
* Registra um único evento de telemetria
*/
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = getClientIP(request);
const rateLimit = checkRateLimit(ip, "/api/telemetry/event");
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetAt);
}
// Autenticação opcional (usuários anônimos podem gerar eventos)
const authResult = await optionalAuth(request);
const userId = authResult instanceof NextResponse ? undefined : authResult?.userId;
const body = await request.json();
// Verificar se é batch ou evento único
const isBatch = body.events && Array.isArray(body.events) && body.events.length > 0;
if (isBatch) {
// Batch de eventos
// Garantir que todos os eventos tenham metadata
const normalizedBody = {
...body,
events: body.events.map((event: any) => ({
...event,
metadata: event.metadata || {},
})),
};
const validationResult = telemetryBatchSchema.safeParse(normalizedBody);
if (!validationResult.success) {
return NextResponse.json(
{
success: false,
error: "Dados de entrada inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
);
}
const { events, session_id: batchSessionId } = validationResult.data;
// Adicionar user_id e session_id aos eventos
const eventsWithAuth = events.map((event) => ({
...event,
user_id: userId,
session_id: batchSessionId || event.session_id || undefined,
metadata: event.metadata || {},
}));
const trackedEvents = await trackEventsBatch(eventsWithAuth);
secureLogger.debug("📊 Batch de eventos registrado", {
count: trackedEvents.length,
userId,
ip,
});
return NextResponse.json(
{
success: true,
events: trackedEvents,
},
{ status: 201 }
);
} else {
// Evento único
// Garantir que o evento tenha metadata
const normalizedBody = {
...body,
metadata: body.metadata || {},
};
const validationResult = telemetryEventSchema.safeParse(normalizedBody);
if (!validationResult.success) {
return NextResponse.json(
{
success: false,
error: "Dados de entrada inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
);
}
const eventData = validationResult.data;
// Adicionar user_id e session_id
const trackedEvent = await trackEvent({
...eventData,
user_id: userId,
session_id: body.session_id || undefined,
metadata: eventData.metadata || {},
});
secureLogger.debug("📊 Evento registrado", {
eventType: eventData.event_type,
adId: eventData.ad_id,
userId,
ip,
});
return NextResponse.json(
{
success: true,
event: trackedEvent,
},
{ status: 201 }
);
}
} catch (error) {
secureLogger.error("❌ Erro ao registrar evento de telemetria", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{
success: false,
error: "Erro ao registrar evento",
},
{ status: 500 }
);
}
}
/**
* GET /api/telemetry/analytics/:adId
* Busca analytics de um anúncio específico
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const adIdParam = searchParams.get("adId");
const dashboard = searchParams.get("dashboard");
// Dashboard agregado (admin)
if (dashboard === "true") {
const startDateParam = searchParams.get("startDate");
const endDateParam = searchParams.get("endDate");
const startDate = startDateParam ? new Date(startDateParam) : undefined;
const endDate = endDateParam ? new Date(endDateParam) : undefined;
const stats = await getMarketplaceStats(startDate, endDate);
return NextResponse.json(
{
success: true,
stats,
},
{ status: 200 }
);
}
// Analytics de um anúncio específico
if (adIdParam) {
const adId = parseInt(adIdParam, 10);
if (isNaN(adId)) {
return NextResponse.json(
{
success: false,
error: "ID do anúncio inválido",
},
{ status: 400 }
);
}
const analytics = await getAdAnalytics(adId);
if (!analytics) {
return NextResponse.json(
{
success: false,
error: "Analytics não encontrado para este anúncio",
},
{ status: 404 }
);
}
return NextResponse.json(
{
success: true,
analytics,
},
{ status: 200 }
);
}
return NextResponse.json(
{
success: false,
error: "Parâmetro 'adId' ou 'dashboard' é obrigatório",
},
{ status: 400 }
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar analytics", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{
success: false,
error: "Erro ao buscar analytics",
},
{ status: 500 }
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { getUserById } from "@/lib/db/queries/users";
import { getUserById } from "@/lib/prisma/queries/users";
export async function GET(
request: NextRequest,
+1 -1
View File
@@ -5,7 +5,7 @@ import {
updateCharacter,
deleteCharacter,
getCharacterById,
} from "@/lib/db/queries/characters";
} from "@/lib/prisma/queries/characters";
import { characterSchema } from "@/lib/validation-schemas";
export async function PUT(
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserCharacters, createCharacter } from "@/lib/db/queries/characters";
import { getUserCharacters, createCharacter } from "@/lib/prisma/queries/characters";
import { characterSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserById, updateUser } from "@/lib/db/queries/users";
import { getUserCharacters } from "@/lib/db/queries/characters";
import { getUserById, updateUser } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters";
import { updateUserSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) {