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:
1 parent
f35fee944d
commit
7f42def478
55 files changed
+4906
-95
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface SessionUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
characterName: string | null;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user