feat(market): add Minha Intenções tab with panel, hook, and badge

- Introduced "Minhas Intenções" tab visible only to logged-in users.
- Integrated MyPurchaseIntentionsPanel and useMyPurchaseIntentions hook.
- Added tab badge showing count of pending intentions.
- Extended session API to include user’s main character for downstream features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-07 17:25:51 -03:00
1 parent f35fee944d
commit 7f42def478
55 files changed
+4906 -95

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ function getJWTRefreshSecret(): string {
}
// Tempos de expiração
const ACCESS_TOKEN_EXPIRY = "15m"; // 15 minutos
const ACCESS_TOKEN_EXPIRY = "1h"; // 1 hora
const REFRESH_TOKEN_EXPIRY = "7d"; // 7 dias
/**
+139
View File
@@ -0,0 +1,139 @@
/**
* Funções para criar mensagens do sistema no chat
*/
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
import { Message } from "@/lib/mongodb/models/Message";
import { getSocketIO } from "@/lib/socket/server";
import { secureLogger } from "@/lib/secure-logger";
interface CreateSystemMessageParams {
listingId: number;
buyerId: number;
sellerId: number;
message: string;
metadata?: {
action?: string;
[key: string]: any;
};
}
/**
* Cria uma mensagem do sistema no chat e notifica os participantes
*/
export async function createSystemMessageInChat(
params: CreateSystemMessageParams
): Promise<void> {
try {
await connectMongoDB();
// Gerar chat_id (mesmo formato usado na criação de chat)
const chatId = `${params.listingId}_${params.buyerId}_${params.sellerId}`;
// Buscar ou criar chat
let chat = await Chat.findOne({ chat_id: chatId });
if (!chat) {
// Se o chat não existe, criar (isso pode acontecer se a intenção foi criada antes do chat)
// Buscar dados do listing para cache
const { getListingById } = await import("@/lib/prisma/queries/listings");
const listing = await getListingById(params.listingId);
if (!listing) {
throw new Error("Listing não encontrado");
}
// Buscar dados dos usuários
const { getUserById } = await import("@/lib/prisma/queries/users");
const buyer = await getUserById(params.buyerId);
const seller = await getUserById(params.sellerId);
if (!buyer || !seller) {
throw new Error("Usuário não encontrado");
}
// Buscar personagem principal do comprador e vendedor
const { getMainCharacter } = await import("@/lib/prisma/queries/characters");
const buyerCharacter = await getMainCharacter(params.buyerId);
const sellerCharacter = await getMainCharacter(params.sellerId);
chat = new Chat({
chat_id: chatId,
listing_id: params.listingId,
buyer_id: params.buyerId,
seller_id: params.sellerId,
is_active: true,
buyer_unread_count: 0,
seller_unread_count: 1, // Vendedor será notificado
listing_cache: {
item_name: listing.item_name,
item_image_url: null, // Pode ser preenchido depois se necessário
price_pix: listing.price_pix ? Number(listing.price_pix) : undefined,
price_tibia_coins: listing.price_tibia_coins || undefined,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : undefined,
server_name: listing.server_name,
listing_status: listing.listing_status,
},
buyer_cache: {
user_id: buyer.user_id,
username: buyer.username,
character_name: buyerCharacter?.character_name || null,
},
seller_cache: {
user_id: seller.user_id,
username: seller.username,
character_name: sellerCharacter?.character_name || null,
},
});
await chat.save();
}
// Criar mensagem do sistema
const systemMessage = new Message({
chat_id: chatId,
sender_id: 0, // 0 = sistema
content: params.message,
message_type: "system",
metadata: params.metadata || {},
});
await systemMessage.save();
// Atualizar chat
chat.last_message_at = new Date();
chat.last_message_preview = params.message.substring(0, 100);
chat.seller_unread_count += 1; // Notificar vendedor
await chat.save();
// Emitir mensagem via Socket.io para participantes do chat
const io = getSocketIO();
if (io) {
io.to(`chat:${chatId}`).emit("new_message", {
message: {
_id: systemMessage._id.toString(),
chat_id: systemMessage.chat_id,
sender_id: 0,
content: systemMessage.content,
created_at: systemMessage.created_at.toISOString(),
read_at: null,
message_type: "system",
metadata: systemMessage.metadata,
},
});
}
secureLogger.info("✅ Mensagem do sistema criada no chat", {
chatId,
message: params.message,
action: params.metadata?.action,
});
} catch (error) {
secureLogger.error("❌ Erro ao criar mensagem do sistema no chat", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
}
@@ -0,0 +1,13 @@
-- Adicionar campo para contar trocas de método de pagamento
ALTER TABLE purchase_intentions
ADD COLUMN IF NOT EXISTS payment_method_change_count INT DEFAULT 0;
-- Atualizar registros existentes para 0
UPDATE purchase_intentions
SET payment_method_change_count = 0
WHERE payment_method_change_count IS NULL;
-- Adicionar índice para queries de verificação
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_buyer_listing
ON purchase_intentions(buyer_id, listing_id, status);
+6 -3
View File
@@ -18,7 +18,8 @@ import { getPool, query, healthCheck } from "../index";
const MIGRATIONS_DIR = join(__dirname, ".");
// Lista de migrations em ordem
const MIGRATIONS = [ "001_users.sql",
const MIGRATIONS = [
"001_users.sql",
"002_servers.sql",
"003_fix_servers_location.sql",
"004_listings.sql",
@@ -33,9 +34,11 @@ const MIGRATIONS = [ "001_users.sql",
"013_add_trade_item_support.sql",
"014_add_bid_increment.sql",
"015_add_purchase_intentions.sql",
"016_add_payment_method_change_count.sql",
"016_create_unified_items_table.sql",
"017_add_items_foreign_key.sql",
"018_migrate_listings_to_unified_items.sql",];
"018_migrate_listings_to_unified_items.sql",
];
async function runMigrations() {
console.log("🚀 Iniciando execução de migrations...\n");
@@ -84,7 +87,7 @@ async function runMigrations() {
console.log(`📝 Executando migration: ${migrationFile}...`);
// Ler arquivo SQL
const sql = readFileSync(migrationPath, "utf-8");
const sql = readFileSync(migrationPath, "utf-8").replace(/^\uFEFF/, "");
// Executar migration
await query(sql);
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSocket } from "./use-socket";
import { useSession } from "./use-session";
import type { Socket } from "socket.io-client";
export interface ChatMessage {
_id: string;
chat_id: string;
sender_id: number;
content: string;
created_at: string;
read_at: string | null;
edited_at: string | null;
message_type: "text" | "system";
metadata?: any;
}
export interface Chat {
chat_id: string;
listing_id: number;
buyer_id: number;
seller_id: number;
created_at: string;
updated_at: string;
last_message_at: string | null;
last_message_preview: string | null;
buyer_unread_count: number;
seller_unread_count: number;
is_active: boolean;
listing_cache: {
item_name: string;
item_image_url?: string;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
server_name: string;
listing_status: string;
};
buyer_cache: {
user_id: number;
username: string;
character_name?: string;
};
seller_cache: {
user_id: number;
username: string;
character_name?: string;
};
}
interface UseChatReturn {
chat: Chat | null;
messages: ChatMessage[];
isLoading: boolean;
isSending: boolean;
error: string | null;
sendMessage: (content: string) => Promise<void>;
loadMoreMessages: () => Promise<void>;
hasMore: boolean;
isTyping: boolean;
otherUserTyping: boolean;
}
/**
* Hook para gerenciar um chat específico
*/
export function useChat(chatId: string | null): UseChatReturn {
const { user } = useSession();
const { socket, isConnected } = useSocket();
const queryClient = useQueryClient();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isSending, setIsSending] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [otherUserTyping, setOtherUserTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Buscar dados do chat
const { data: chatData, isLoading: isLoadingChat } = useQuery<{
success: boolean;
chat: Chat;
}>({
queryKey: ["chat", chatId],
queryFn: async () => {
if (!chatId) return null;
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) throw new Error("Erro ao buscar chat");
return res.json();
},
enabled: !!chatId && !!user,
staleTime: 1000 * 30,
});
// Buscar mensagens
const { data: messagesData, isLoading: isLoadingMessages } = useQuery<{
success: boolean;
messages: ChatMessage[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}>({
queryKey: ["chat-messages", chatId, page],
queryFn: async () => {
if (!chatId) return null;
const res = await fetch(`/api/chats/${chatId}/messages?page=${page}&limit=50`);
if (!res.ok) throw new Error("Erro ao buscar mensagens");
return res.json();
},
enabled: !!chatId && !!user,
staleTime: 1000 * 10,
});
// Atualizar mensagens quando dados mudarem
useEffect(() => {
if (messagesData?.messages) {
if (page === 1) {
setMessages(messagesData.messages);
} else {
// Adicionar mensagens antigas no início
setMessages((prev) => [...messagesData.messages, ...prev]);
}
setHasMore(messagesData.pagination.page < messagesData.pagination.totalPages);
}
}, [messagesData, page]);
// Conectar ao Socket.io quando chatId mudar
useEffect(() => {
if (!socket || !isConnected || !chatId) return;
// Entrar no chat
socket.emit("join_chat", { chat_id: chatId });
// Listener para novas mensagens
const handleNewMessage = (data: { message: ChatMessage }) => {
console.log("[CHAT-DEBUG] useChat: new_message recebido", { message: data.message });
// Verificar se a mensagem já existe para evitar duplicação
setMessages((prev) => {
const messageExists = prev.some((msg) => msg._id === data.message._id);
if (messageExists) {
console.log("[CHAT-DEBUG] Mensagem já existe, ignorando duplicação", { messageId: data.message._id });
return prev;
}
return [...prev, data.message];
});
// Invalidar cache
queryClient.invalidateQueries({ queryKey: ["chat-messages", chatId] });
queryClient.invalidateQueries({ queryKey: ["chat", chatId] });
};
// Listener para indicador de digitação
const handleTyping = (data: { user_id: number; username: string; is_typing: boolean }) => {
if (data.user_id !== user?.userId) {
setOtherUserTyping(data.is_typing);
}
};
socket.on("new_message", handleNewMessage);
socket.on("user_typing", handleTyping);
return () => {
socket.off("new_message", handleNewMessage);
socket.off("user_typing", handleTyping);
};
}, [socket, isConnected, chatId, user, queryClient]);
// Função para enviar mensagem
const sendMessage = useCallback(
async (content: string) => {
if (!chatId || !socket || !isConnected || !content.trim()) return;
console.log("[CHAT-DEBUG] sendMessage chamado", { chatId, content, socketConnected: isConnected });
setIsSending(true);
try {
// Enviar via Socket.io
socket.emit("send_message", {
chat_id: chatId,
content: content.trim(),
});
// REMOVIDO: Otimistic update que causava duplicação
// A mensagem será adicionada automaticamente quando o servidor emitir "new_message"
// Parar de digitar
setIsTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
socket.emit("typing", { chat_id: chatId, is_typing: false });
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao enviar mensagem:", error);
} finally {
setIsSending(false);
}
},
[chatId, socket, isConnected, user]
);
// Função para carregar mais mensagens
const loadMoreMessages = useCallback(async () => {
if (!hasMore || isLoadingMessages) return;
setPage((prev) => prev + 1);
}, [hasMore, isLoadingMessages]);
return {
chat: chatData?.chat || null,
messages,
isLoading: isLoadingChat || isLoadingMessages,
isSending,
error: null,
sendMessage,
loadMoreMessages,
hasMore,
isTyping,
otherUserTyping,
};
}
// Exportar handlers de digitação separadamente se necessário
export function useChatTyping(
socket: Socket | null,
isConnected: boolean,
chatId: string | null
) {
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleTypingStart = useCallback(() => {
if (!socket || !isConnected || !chatId || isTyping) return;
setIsTyping(true);
socket.emit("typing", { chat_id: chatId, is_typing: true });
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false);
socket.emit("typing", { chat_id: chatId, is_typing: false });
}, 3000);
}, [socket, isConnected, chatId, isTyping]);
const handleTypingStop = useCallback(() => {
if (!socket || !isConnected || !chatId || !isTyping) return;
setIsTyping(false);
socket.emit("typing", { chat_id: chatId, is_typing: false });
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}, [socket, isConnected, chatId, isTyping]);
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return { handleTypingStart, handleTypingStop };
}
+1
View File
@@ -24,6 +24,7 @@ export interface Listing {
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
has_pending_intention_for_current_user?: boolean;
}
interface ListingsResponse {
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export type PurchaseIntentionStatus =
| "pending"
| "accepted"
| "rejected"
| "expired"
| "completed";
export interface PurchaseIntentionListing {
listing_id: number;
item_id: number;
item_id_unified: number | null;
item_name: string;
quantity: number;
server_name: string;
seller_id: number;
price_pix: number | null;
price_tibia_coins: number | null;
price_gold_coins: number | null;
listing_status: string;
created_at: Date | string;
description: string | null;
trade_item_id: number | null;
trade_item_name: string | null;
trade_item_quantity: number | null;
views_count: number;
favorites_count: number;
is_featured: boolean;
image_url: string | null;
image_local_path: string | null;
item_category: string | null;
seller_character_name: string | null;
seller_username: string | null;
seller_email: string | null;
}
export interface PurchaseIntention {
intention_id: number;
listing_id: number;
buyer_id: number;
payment_method: string;
payment_method_change_count: number;
chat_id: string | null;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
created_at: Date | string;
updated_at: Date | string;
expires_at: Date | string | null;
accepted_at: Date | string | null;
rejected_at: Date | string | null;
listing: PurchaseIntentionListing | null;
}
interface PurchaseIntentionsResponse {
success: boolean;
intentions: PurchaseIntention[];
count: number;
}
/**
* Hook para buscar intenções de compra do usuário autenticado
* Atualiza automaticamente a cada 30 segundos
*/
export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) {
const queryParams = new URLSearchParams();
if (status) queryParams.set("status", status);
queryParams.set("limit", "100"); // Limite alto para mostrar todas
const { data, isLoading, error, refetch } = useQuery<PurchaseIntentionsResponse>({
queryKey: ["my-purchase-intentions", status],
queryFn: async () => {
const res = await fetch(`/api/purchase-intentions/my?${queryParams.toString()}`);
if (!res.ok) {
if (res.status === 401) {
throw new Error("Não autenticado");
}
throw new Error("Erro ao buscar intenções de compra");
}
return res.json();
},
staleTime: 1000 * 30, // 30 segundos
refetchInterval: 1000 * 30, // Refetch a cada 30 segundos
refetchOnWindowFocus: true,
refetchOnReconnect: true,
enabled: true, // Sempre habilitado (autenticação é verificada no backend)
});
return {
intentions: data?.intentions || [],
count: data?.count || 0,
isLoading,
error,
refetch,
};
}
/**
* Hook para invalidar cache de purchase intentions
*/
export function useInvalidatePurchaseIntentions() {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["my-purchase-intentions"] });
};
return invalidate;
}
+1
View File
@@ -6,6 +6,7 @@ export interface SessionUser {
userId: number;
username: string;
email: string;
characterName: string | null;
}
interface SessionResponse {
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import { useSession } from "./use-session";
interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
error: string | null;
}
/**
* Hook para gerenciar conexão Socket.io
*/
export function useSocket(): UseSocketReturn {
const { user } = useSession();
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (!user) {
// Desconectar se usuário não estiver autenticado
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setSocket(null);
setIsConnected(false);
}
return;
}
// Conectar ao Socket.io
const connectSocket = async () => {
if (socketRef.current?.connected) {
return;
}
try {
const newSocket = io(process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000", {
path: "/api/socket",
transports: ["websocket", "polling"],
withCredentials: true, // Enviar cookies automaticamente
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
newSocket.on("connect", () => {
setIsConnected(true);
setError(null);
});
newSocket.on("disconnect", () => {
setIsConnected(false);
});
newSocket.on("connect_error", (err) => {
setError(err.message);
setIsConnected(false);
});
socketRef.current = newSocket;
setSocket(newSocket);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao conectar");
setIsConnected(false);
}
};
connectSocket();
// Cleanup
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setSocket(null);
setIsConnected(false);
}
};
}, [user]);
return {
socket,
isConnected,
error,
};
}
+2 -21
View File
@@ -84,28 +84,9 @@ export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
return { items: [], total: 0 } as SearchResult<T>;
}
try {
// Log básico do schema na primeira chamada
// Schema logging desabilitado para reduzir poluição do console
if (!schemaLogged) {
try {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[];
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
for (const t of tables.slice(0, 5)) {
try {
const cols = db.prepare(`PRAGMA table_info(${t.name})`).all() as any[];
console.info(`[items-db] Colunas ${t.name}:`, cols.map((c) => `${c.name}:${c.type}`));
const sample = db.prepare(`SELECT * FROM ${t.name} LIMIT 3`).all();
console.info(`[items-db] Amostra ${t.name}:`, sample);
} catch (inner) {
console.warn(`[items-db] Falha ao inspecionar ${t.name}:`, (inner as any)?.message);
}
}
} catch (intErr) {
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
} finally {
schemaLogged = true;
}
schemaLogged = true;
}
const where: string[] = [];
+82
View File
@@ -0,0 +1,82 @@
/**
* Conexão MongoDB usando Mongoose
* Singleton pattern para reutilizar conexão
*/
import mongoose from "mongoose";
function getMongoUrl(): string {
const url = process.env.MONGO_URL;
if (!url) {
throw new Error("MONGO_URL não está definida nas variáveis de ambiente");
}
return url;
}
interface MongooseCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
// Cache global para Next.js (evita múltiplas conexões em hot reload)
declare global {
// eslint-disable-next-line no-var
var mongoose: MongooseCache | undefined;
}
let cached: MongooseCache = global.mongoose || { conn: null, promise: null };
if (!global.mongoose) {
global.mongoose = cached;
}
/**
* Conecta ao MongoDB ou retorna conexão existente
*/
export async function connectMongoDB(): Promise<typeof mongoose> {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
};
cached.promise = mongoose
.connect(getMongoUrl(), opts)
.then((mongoose) => {
// Conexão MongoDB estabelecida (log desabilitado)
return mongoose;
})
.catch((error) => {
console.error("❌ Erro ao conectar MongoDB:", error);
cached.promise = null;
throw error;
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}
/**
* Desconecta do MongoDB (útil para testes)
*/
export async function disconnectMongoDB(): Promise<void> {
if (cached.conn) {
await mongoose.disconnect();
cached.conn = null;
cached.promise = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Model Chat - Representa uma conversa entre comprador e vendedor
*/
import mongoose, { Schema, model, models } from "mongoose";
export interface IChat {
chat_id: string; // ID único: `${listing_id}_${buyer_id}_${seller_id}`
listing_id: number; // ID do anúncio (PostgreSQL)
buyer_id: number; // ID do comprador (PostgreSQL)
seller_id: number; // ID do vendedor (PostgreSQL)
created_at: Date;
updated_at: Date;
last_message_at: Date | null;
last_message_preview: string | null;
buyer_unread_count: number;
seller_unread_count: number;
is_active: boolean; // Chat ativo (anúncio ainda disponível)
// Dados cached do anúncio (para performance)
listing_cache: {
item_name: string;
item_image_url?: string;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
server_name: string;
listing_status: string;
};
// Dados cached dos usuários
buyer_cache: {
user_id: number;
username: string;
character_name?: string;
};
seller_cache: {
user_id: number;
username: string;
character_name?: string;
};
}
const ChatSchema = new Schema<IChat>(
{
chat_id: {
type: String,
required: true,
unique: true,
index: true,
},
listing_id: {
type: Number,
required: true,
index: true,
},
buyer_id: {
type: Number,
required: true,
index: true,
},
seller_id: {
type: Number,
required: true,
index: true,
},
created_at: {
type: Date,
default: Date.now,
index: true,
},
updated_at: {
type: Date,
default: Date.now,
},
last_message_at: {
type: Date,
default: null,
index: true,
},
last_message_preview: {
type: String,
default: null,
maxlength: 100,
},
buyer_unread_count: {
type: Number,
default: 0,
},
seller_unread_count: {
type: Number,
default: 0,
},
is_active: {
type: Boolean,
default: true,
index: true,
},
listing_cache: {
item_name: { type: String, required: true },
item_image_url: { type: String, default: null },
price_pix: { type: Number, default: null },
price_tibia_coins: { type: Number, default: null },
price_gold_coins: { type: Number, default: null },
server_name: { type: String, required: true },
listing_status: { type: String, required: true },
},
buyer_cache: {
user_id: { type: Number, required: true },
username: { type: String, required: true },
character_name: { type: String, default: null },
},
seller_cache: {
user_id: { type: Number, required: true },
username: { type: String, required: true },
character_name: { type: String, default: null },
},
},
{
timestamps: false, // Usamos campos manuais
collection: "chats",
}
);
// Índices compostos para queries eficientes
ChatSchema.index({ buyer_id: 1, updated_at: -1 });
ChatSchema.index({ seller_id: 1, updated_at: -1 });
ChatSchema.index({ listing_id: 1, buyer_id: 1, seller_id: 1 });
// Atualizar updated_at antes de salvar
ChatSchema.pre("save", function (next) {
this.updated_at = new Date();
next();
});
export const Chat = models.Chat || model<IChat>("Chat", ChatSchema);
+81
View File
@@ -0,0 +1,81 @@
/**
* Model Message - Representa uma mensagem em um chat
*/
import mongoose, { Schema, model, models } from "mongoose";
export interface IMessage {
chat_id: string; // Referência ao chat
sender_id: number; // ID do remetente (PostgreSQL)
content: string; // Conteúdo da mensagem (sanitizado)
created_at: Date;
read_at: Date | null; // Quando foi lida
edited_at: Date | null; // Se foi editada
deleted_at: Date | null; // Soft delete
// Metadados
message_type: "text" | "system"; // System para mensagens automáticas
metadata?: {
listing_id?: number;
action?: "listing_updated" | "listing_sold" | "price_changed";
};
}
const MessageSchema = new Schema<IMessage>(
{
chat_id: {
type: String,
required: true,
index: true,
},
sender_id: {
type: Number,
required: true,
index: true,
},
content: {
type: String,
required: true,
maxlength: 1000,
trim: true,
},
created_at: {
type: Date,
default: Date.now,
index: true,
},
read_at: {
type: Date,
default: null,
},
edited_at: {
type: Date,
default: null,
},
deleted_at: {
type: Date,
default: null,
},
message_type: {
type: String,
enum: ["text", "system"],
default: "text",
},
metadata: {
type: Schema.Types.Mixed,
default: null,
},
},
{
timestamps: false, // Usamos campos manuais
collection: "messages",
}
);
// Índices compostos para queries eficientes
MessageSchema.index({ chat_id: 1, created_at: -1 }); // Para buscar mensagens de um chat ordenadas
MessageSchema.index({ chat_id: 1, sender_id: 1, created_at: -1 });
MessageSchema.index({ sender_id: 1, created_at: -1 });
export const Message = models.Message || model<IMessage>("Message", MessageSchema);
+1 -4
View File
@@ -12,10 +12,7 @@ const globalForPrisma = globalThis as unknown as {
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
log: ["error"], // Apenas erros, sem queries
});
if (process.env.NODE_ENV !== "production") {
+15
View File
@@ -69,6 +69,21 @@ export async function getUserCharacters(userId: number): Promise<Character[]> {
});
}
/**
* Busca personagem principal de um usuário
*/
export async function getMainCharacter(userId: number): Promise<Character | null> {
return await prisma.userCharacter.findFirst({
where: {
user_id: userId,
is_main: true,
},
orderBy: [
{ created_at: "asc" },
],
});
}
/**
* Busca personagem principal de um usuário em um servidor específico
*/
-6
View File
@@ -36,7 +36,6 @@ export interface SearchItemsParams {
* Busca um item por ID unificado
*/
export async function getItemById(itemId: number): Promise<Item | null> {
// @ts-expect-error - Prisma Client pode não ter sido gerado ainda
const item = await prisma.item.findUnique({
where: { item_id: itemId },
});
@@ -56,7 +55,6 @@ export async function getItemById(itemId: number): Promise<Item | null> {
* Busca um item por nome (exato ou similar)
*/
export async function getItemByName(itemName: string): Promise<Item | null> {
// @ts-expect-error - Prisma Client pode não ter sido gerado ainda
const item = await prisma.item.findFirst({
where: {
item_name: {
@@ -91,7 +89,6 @@ export async function searchItems(
pageSize = 24,
} = params;
// @ts-expect-error - ItemWhereInput pode não estar disponível se Prisma Client não foi gerado
const where: Prisma.ItemWhereInput = {};
if (q) {
@@ -109,10 +106,8 @@ export async function searchItems(
where.rarity = rarity;
}
// @ts-expect-error - Prisma Client pode não ter sido gerado ainda
const total = await prisma.item.count({ where });
// @ts-expect-error - Prisma Client pode não ter sido gerado ainda
const items = await prisma.item.findMany({
where,
orderBy: { item_name: "asc" },
@@ -138,7 +133,6 @@ export async function getItemBySource(
sourceTableName: string,
sourceTableId: number
): Promise<Item | null> {
// @ts-expect-error - Prisma Client pode não ter sido gerado ainda
const item = await prisma.item.findUnique({
where: {
source_table_name_source_table_id: {
+121
View File
@@ -20,6 +20,7 @@ export interface PurchaseIntention {
listing_id: number;
buyer_id: number;
payment_method: string;
payment_method_change_count: number;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
@@ -251,3 +252,123 @@ export async function hasPendingIntention(
return count > 0;
}
/**
* Busca intenção pendente de um comprador para um listing específico
*/
export async function getPendingIntentionByBuyerAndListing(
listingId: number,
buyerId: number
): Promise<PurchaseIntention | null> {
return await prisma.purchaseIntention.findFirst({
where: {
listing_id: listingId,
buyer_id: buyerId,
status: "pending",
},
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
orderBy: {
created_at: "desc",
},
}) as PurchaseIntention | null;
}
/**
* Atualiza o método de pagamento de uma intenção
*/
export async function updatePurchaseIntentionPaymentMethod(
intentionId: number,
newPaymentMethod: PaymentMethod
): Promise<PurchaseIntention> {
// Buscar intenção atual
const intention = await prisma.purchaseIntention.findUnique({
where: { intention_id: intentionId },
});
if (!intention) {
throw new Error("Intenção não encontrada");
}
// Verificar se pode trocar (máximo 1 troca)
if (intention.payment_method_change_count >= 1) {
throw new Error("Você já atingiu o limite de trocas de método de pagamento");
}
// Verificar se o novo método é diferente
if (intention.payment_method === newPaymentMethod) {
throw new Error("O novo método de pagamento deve ser diferente do atual");
}
// Atualizar
return await prisma.purchaseIntention.update({
where: { intention_id: intentionId },
data: {
payment_method: newPaymentMethod,
payment_method_change_count: intention.payment_method_change_count + 1,
updated_at: new Date(),
},
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention;
}
/**
* Retorna os IDs de anúncios com intenções pendentes para um comprador
*/
export async function getPendingIntentionListingIdsForBuyer(
buyerId: number,
listingIds: number[]
): Promise<number[]> {
if (listingIds.length === 0) {
return [];
}
const intentions = await prisma.purchaseIntention.findMany({
where: {
buyer_id: buyerId,
status: "pending",
listing_id: {
in: listingIds,
},
},
select: {
listing_id: true,
},
});
const uniqueIds = new Set<number>();
intentions.forEach((intention) => {
uniqueIds.add(intention.listing_id);
});
return Array.from(uniqueIds);
}
+270
View File
@@ -0,0 +1,270 @@
/**
* Socket.io Server
* Integrado com Next.js
*/
import { Server as SocketIOServer } from "socket.io";
import type { Server as HTTPServer } from "http";
import { verifyAccessToken } from "@/lib/auth/jwt";
import { secureLogger } from "@/lib/secure-logger";
import { Message } from "@/lib/mongodb/models/Message";
import { Chat } from "@/lib/mongodb/models/Chat";
import { connectMongoDB } from "@/lib/mongodb/connection";
let io: SocketIOServer | null = null;
/**
* Inicializa o servidor Socket.io
*/
export function initSocketIO(httpServer?: HTTPServer): SocketIOServer {
if (io) {
return io;
}
if (!httpServer) {
throw new Error("HTTP Server é necessário para inicializar Socket.io");
}
io = new SocketIOServer(httpServer, {
path: "/api/socket",
addTrailingSlash: false,
cors: {
origin: process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
methods: ["GET", "POST"],
credentials: true,
},
});
// Middleware de autenticação
io.use(async (socket, next) => {
try {
// Tentar obter token do auth primeiro
const token = socket.handshake.auth.token;
if (token) {
const payload = verifyAccessToken(token);
if (payload) {
socket.data.userId = payload.userId;
socket.data.username = payload.username;
return next();
}
}
// Se não tiver token, tentar via cookie sessionId
const cookies = socket.handshake.headers.cookie;
if (cookies) {
const sessionIdMatch = cookies.match(/sessionId=([^;]+)/);
if (sessionIdMatch) {
const sessionId = sessionIdMatch[1];
const { getSession } = await import("@/lib/redis/session");
const session = await getSession(sessionId);
if (session) {
const payload = verifyAccessToken(session.accessToken);
if (payload) {
socket.data.userId = payload.userId;
socket.data.username = payload.username;
return next();
}
}
}
}
return next(new Error("Token não fornecido ou inválido"));
} catch (error) {
secureLogger.error("Erro na autenticação Socket.io", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
next(new Error("Erro na autenticação"));
}
});
// Event handlers
io.on("connection", (socket) => {
const userId = socket.data.userId;
const username = socket.data.username;
// Cliente conectado (log desabilitado para reduzir poluição do console)
// Entrar em rooms dos chats do usuário
socket.on("join_chats", async () => {
try {
await connectMongoDB();
// Buscar todos os chats do usuário
const chats = await Chat.find({
$or: [{ buyer_id: userId }, { seller_id: userId }],
is_active: true,
}).select("chat_id");
chats.forEach((chat) => {
socket.join(`chat:${chat.chat_id}`);
});
// Usuário entrou nos chats (log desabilitado)
} catch (error) {
secureLogger.error("Erro ao entrar em chats", {
userId,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
}
});
// Entrar em um chat específico
socket.on("join_chat", async (data: { chat_id: string }) => {
try {
const { chat_id } = data;
// Verificar se o usuário pertence ao chat
await connectMongoDB();
const chat = await Chat.findOne({ chat_id });
if (!chat) {
socket.emit("error", { message: "Chat não encontrado" });
return;
}
if (chat.buyer_id !== userId && chat.seller_id !== userId) {
socket.emit("error", { message: "Você não tem permissão para acessar este chat" });
return;
}
socket.join(`chat:${chat_id}`);
// Marcar mensagens como lidas
await Message.updateMany(
{
chat_id,
sender_id: { $ne: userId },
read_at: null,
},
{
$set: { read_at: new Date() },
}
);
// Atualizar contador de não lidas
if (chat.buyer_id === userId) {
chat.buyer_unread_count = 0;
} else {
chat.seller_unread_count = 0;
}
await chat.save();
// Usuário entrou no chat (log desabilitado)
} catch (error) {
secureLogger.error("Erro ao entrar no chat", {
userId,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
socket.emit("error", { message: "Erro ao entrar no chat" });
}
});
// Enviar mensagem
socket.on("send_message", async (data: { chat_id: string; content: string }) => {
try {
const { chat_id, content } = data;
if (!content || content.trim().length === 0) {
socket.emit("error", { message: "Mensagem não pode estar vazia" });
return;
}
if (content.length > 1000) {
socket.emit("error", { message: "Mensagem muito longa (máximo 1000 caracteres)" });
return;
}
await connectMongoDB();
// Verificar se o chat existe e o usuário pertence a ele
const chat = await Chat.findOne({ chat_id });
if (!chat) {
socket.emit("error", { message: "Chat não encontrado" });
return;
}
if (chat.buyer_id !== userId && chat.seller_id !== userId) {
socket.emit("error", { message: "Você não tem permissão para enviar mensagens neste chat" });
return;
}
// Criar mensagem
const message = new Message({
chat_id,
sender_id: userId,
content: content.trim(),
message_type: "text",
});
await message.save();
// Atualizar chat
chat.last_message_at = new Date();
chat.last_message_preview = content.substring(0, 100);
// Atualizar contador de não lidas
if (chat.buyer_id === userId) {
chat.seller_unread_count += 1;
} else {
chat.buyer_unread_count += 1;
}
await chat.save();
// Enviar mensagem para todos no chat (exceto o remetente)
io!.to(`chat:${chat_id}`).emit("new_message", {
message: {
_id: message._id.toString(),
chat_id: message.chat_id,
sender_id: message.sender_id,
content: message.content,
created_at: message.created_at,
read_at: message.read_at,
message_type: message.message_type,
},
});
// Confirmar para o remetente
socket.emit("message_sent", {
message_id: message._id.toString(),
});
// Mensagem enviada (log desabilitado)
} catch (error) {
secureLogger.error("Erro ao enviar mensagem", {
userId,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
socket.emit("error", { message: "Erro ao enviar mensagem" });
}
});
// Indicador de "digitando..."
socket.on("typing", (data: { chat_id: string; is_typing: boolean }) => {
const { chat_id, is_typing } = data;
socket.to(`chat:${chat_id}`).emit("user_typing", {
user_id: userId,
username: username,
is_typing,
});
});
// Desconexão
socket.on("disconnect", () => {
// Cliente desconectado (log desabilitado)
});
});
return io;
}
/**
* Obtém a instância do servidor Socket.io
*/
export function getSocketIO(): SocketIOServer | null {
return io;
}
+19
View File
@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Formata uma data para string relativa (ex: "há 2 horas")
*/
export function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
if (days < 30) return `há ${days} ${days === 1 ? "dia" : "dias"}`;
const months = Math.floor(days / 30);
if (months < 12) return `há ${months} ${months === 1 ? "mês" : "meses"}`;
const years = Math.floor(months / 12);
return `há ${years} ${years === 1 ? "ano" : "anos"}`;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Schemas de validação para Chat usando Zod
*/
import { z } from "zod";
import sanitizeHtml from "sanitize-html";
// Schema para criar chat
export const createChatSchema = z.object({
listing_id: z.number().int().positive(),
buyer_message: z.string().max(1000).optional(),
});
// Schema para enviar mensagem
export const sendMessageSchema = z.object({
content: z
.string()
.min(1, "Mensagem não pode estar vazia")
.max(1000, "Mensagem muito longa (máximo 1000 caracteres)")
.transform((val) => sanitizeHtml(val.trim(), { allowedTags: [], allowedAttributes: {} })),
});
// Schema para query params de mensagens
export const getMessagesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(50),
});