feat: implement market page with QueryProvider and dynamic listings
- wrap root layout with QueryProvider for React Query - replace market UnderConstruction placeholder with MarketClient and real data fetching - implement search, pagination, and sort via search params using getActiveListings - add necessary client components and queries - update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
1 parent
55eebde0ba
commit
ab37b477df
106 files changed
+15261
-301
No files matched your search
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getListingById,
|
||||
updateListing,
|
||||
deleteListing,
|
||||
} from "@/lib/db/queries/listings";
|
||||
} from "@/lib/prisma/queries/listings";
|
||||
import { updateListingSchema } from "@/lib/validation-schemas";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user