diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..ce0cb15 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; +import { loginSchema } from "@/lib/validation-schemas"; +import { comparePassword } from "@/lib/auth/password"; +import { + generateAccessToken, + generateRefreshToken, +} from "@/lib/auth/jwt"; +import { + getUserByEmail, + getUserByUsername, + updateLastLogin, +} from "@/lib/db/queries/users"; +import { setSession } from "@/lib/redis/session"; +import { randomUUID } from "crypto"; + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/auth/login"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + const body = await request.json(); + + secureLogger.info("📥 POST /api/auth/login - Recebido", { + ip, + timestamp: new Date().toISOString(), + }); + + // Validação com Zod + const validationResult = loginSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const { emailOrUsername, password } = validationResult.data; + + // Buscar usuário por email ou username + const user = + (await getUserByEmail(emailOrUsername)) || + (await getUserByUsername(emailOrUsername)); + + if (!user) { + secureLogger.warn("⚠️ Tentativa de login com credenciais inválidas", { + emailOrUsername: secureLogger.mask({ emailOrUsername }), + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: "Email/username ou senha incorretos", + }, + { status: 401 } + ); + } + + // Verificar se usuário está ativo + if (!user.is_active) { + return NextResponse.json( + { + success: false, + error: "Conta desativada. Entre em contato com o suporte.", + }, + { status: 403 } + ); + } + + // Verificar senha + if (!user.password_hash) { + secureLogger.error("❌ Usuário sem password_hash", { + userId: user.user_id, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: "Erro interno. Tente novamente mais tarde.", + }, + { status: 500 } + ); + } + + const passwordMatch = await comparePassword(password, user.password_hash); + + if (!passwordMatch) { + secureLogger.warn("⚠️ Senha incorreta", { + userId: user.user_id, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: "Email/username ou senha incorretos", + }, + { status: 401 } + ); + } + + // Atualizar último login + await updateLastLogin(user.user_id); + + // Gerar tokens + const accessToken = generateAccessToken(user.user_id, user.username); + const refreshToken = generateRefreshToken(user.user_id, user.username); + + // Criar sessão no Redis + const sessionId = randomUUID(); + await setSession(sessionId, { + userId: user.user_id, + username: user.username, + email: user.email, + accessToken, + createdAt: Date.now(), + }); + + // Criar resposta + const response = NextResponse.json( + { + success: true, + user: { + userId: user.user_id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + accountLevel: user.account_level, + hasDonated: user.has_donated, + }, + accessToken, + }, + { status: 200 } + ); + + // Adicionar refresh token e sessionId como cookies httpOnly + response.cookies.set("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 dias + path: "/", + }); + + response.cookies.set("sessionId", sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 dias + path: "/", + }); + + secureLogger.info("✅ Login realizado com sucesso", { + userId: user.user_id, + username: user.username, + timestamp: new Date().toISOString(), + }); + + return response; + } catch (error) { + secureLogger.error("❌ Erro ao fazer login", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..0083505 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,92 @@ +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"; + +export async function GET(request: NextRequest) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + // Se retornou NextResponse, é um erro de autenticação + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + // Buscar dados do usuário + const user = await getUserById(auth.userId); + + if (!user) { + return NextResponse.json( + { + success: false, + error: "Usuário não encontrado", + }, + { status: 404 } + ); + } + + // Buscar personagens do usuário + const characters = await getUserCharacters(auth.userId); + + secureLogger.info("✅ Perfil do usuário recuperado", { + userId: auth.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + user: { + userId: user.user_id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + phone: user.phone, + cpf: user.cpf, + accountLevel: user.account_level, + hasDonated: user.has_donated, + whatsappNotifications: user.whatsapp_notifications, + createdAt: user.created_at, + updatedAt: user.updated_at, + lastLogin: user.last_login, + isActive: user.is_active, + isVerified: user.is_verified, + }, + characters: characters.map((char) => ({ + characterId: char.character_id, + characterName: char.character_name, + serverName: char.server_name, + isMain: char.is_main, + verified: char.verified, + createdAt: char.created_at, + })), + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + secureLogger.error("❌ Erro ao buscar perfil do usuário", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..1838fc0 --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; +import { verifyRefreshToken, generateAccessToken } from "@/lib/auth/jwt"; + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/auth/refresh"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + // Extrair refresh token do cookie + const refreshToken = request.cookies.get("refreshToken")?.value; + + if (!refreshToken) { + return NextResponse.json( + { + success: false, + error: "Refresh token não fornecido", + }, + { status: 401 } + ); + } + + // Validar refresh token + const payload = verifyRefreshToken(refreshToken); + + if (!payload) { + // Token inválido - limpar cookie + const response = NextResponse.json( + { + success: false, + error: "Refresh token inválido ou expirado", + }, + { status: 401 } + ); + + response.cookies.delete("refreshToken"); + return response; + } + + // Gerar novo access token + const accessToken = generateAccessToken(payload.userId, payload.username); + + secureLogger.info("✅ Token renovado com sucesso", { + userId: payload.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + accessToken, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao renovar token", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..b5feb39 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,270 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; +import { registerSchema } from "@/lib/validation-schemas"; +import { hashPassword } from "@/lib/auth/password"; +import { + generateAccessToken, + generateRefreshToken, +} from "@/lib/auth/jwt"; +import { + getUserByEmail, + getUserByUsername, +} from "@/lib/db/queries/users"; +import { + isServerActive, + incrementUsersForServers, +} from "@/lib/db/queries/servers"; +import { setSession } from "@/lib/redis/session"; +import { randomUUID } from "crypto"; +import { getPool } from "@/lib/db"; + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/auth/register"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + const body = await request.json(); + + secureLogger.info("📥 POST /api/auth/register - Recebido", { + ip, + timestamp: new Date().toISOString(), + }); + + // Validação com Zod + const validationResult = registerSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + secureLogger.error("❌ Validação falhou", { + errors: JSON.stringify(errors), + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Verificar duplicidade: email + const existingEmail = await getUserByEmail(validatedData.email); + if (existingEmail) { + return NextResponse.json( + { + success: false, + error: "Email já está em uso", + }, + { status: 409 } + ); + } + + // Verificar duplicidade: username + const existingUsername = await getUserByUsername(validatedData.username); + if (existingUsername) { + return NextResponse.json( + { + success: false, + error: "Username já está em uso", + }, + { status: 409 } + ); + } + + // Validar servidores: verificar se existem e estão ativos + const serverNames = validatedData.servers.map((s) => s.serverName); + const uniqueServerNames = [...new Set(serverNames)]; // Remover duplicatas + + for (const serverName of uniqueServerNames) { + const isActive = await isServerActive(serverName); + if (!isActive) { + return NextResponse.json( + { + success: false, + error: `Servidor "${serverName}" não existe ou não está ativo`, + }, + { status: 400 } + ); + } + } + + // 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(); + + let newUser: { userId: number; username: string; email: string }; + const createdCharacters: Array<{ characterName: string; serverName: string }> = []; + + try { + await client.query("BEGIN"); + + // 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: 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; + + // 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 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 + await incrementUsersForServers(uniqueServerNames); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + + // Gerar tokens + const accessToken = generateAccessToken(newUser.userId, newUser.username); + const refreshToken = generateRefreshToken( + newUser.userId, + newUser.username + ); + + // Criar sessão no Redis + const sessionId = randomUUID(); + await setSession(sessionId, { + userId: newUser.userId, + username: newUser.username, + email: newUser.email, + accessToken, + createdAt: Date.now(), + }); + + // Criar resposta + const response = NextResponse.json( + { + success: true, + user: { + userId: newUser.userId, + username: newUser.username, + email: newUser.email, + }, + accessToken, + }, + { status: 201 } + ); + + // Adicionar refresh token e sessionId como cookies httpOnly + response.cookies.set("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 dias + path: "/", + }); + + response.cookies.set("sessionId", sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 dias + path: "/", + }); + + secureLogger.info("✅ Usuário registrado com sucesso", { + userId: newUser.userId, + username: newUser.username, + timestamp: new Date().toISOString(), + }); + + return response; + } catch (error) { + secureLogger.error("❌ Erro ao registrar usuário", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..6ca47d1 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { secureLogger } from "@/lib/secure-logger"; +import { getSession, refreshSession, deleteSession } from "@/lib/redis/session"; +import { verifyAccessToken } from "@/lib/auth/jwt"; + +/** + * GET /api/auth/session + * Verifica e retorna dados da sessão atual + */ +export async function GET(request: NextRequest) { + try { + // Buscar sessionId do cookie + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (!sessionId) { + return NextResponse.json( + { + success: false, + authenticated: false, + error: "Nenhuma sessão encontrada", + }, + { status: 401 } + ); + } + + // Buscar sessão no Redis + const session = await getSession(sessionId); + + if (!session) { + // Limpar cookie se sessão não existe + const response = NextResponse.json( + { + success: false, + authenticated: false, + error: "Sessão expirada ou inválida", + }, + { status: 401 } + ); + + response.cookies.delete("sessionId"); + return response; + } + + // Verificar se o access token ainda é válido + const tokenPayload = verifyAccessToken(session.accessToken); + + if (!tokenPayload) { + // Token expirado - remover sessão + await deleteSession(sessionId); + const response = NextResponse.json( + { + success: false, + authenticated: false, + error: "Token expirado", + }, + { status: 401 } + ); + + response.cookies.delete("sessionId"); + return response; + } + + // Atualizar TTL da sessão + await refreshSession(sessionId); + + return NextResponse.json( + { + success: true, + authenticated: true, + user: { + userId: session.userId, + username: session.username, + email: session.email, + }, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao verificar sessão", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + authenticated: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/auth/session + * Encerra a sessão atual + */ +export async function DELETE(request: NextRequest) { + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (sessionId) { + await deleteSession(sessionId); + } + + const response = NextResponse.json( + { + success: true, + message: "Sessão encerrada", + }, + { status: 200 } + ); + + response.cookies.delete("sessionId"); + return response; + } catch (error) { + secureLogger.error("❌ Erro ao encerrar sessão", { + error: error instanceof Error ? error.message : "Erro desconhecido", + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts new file mode 100644 index 0000000..e9a7f6f --- /dev/null +++ b/app/api/listings/[id]/route.ts @@ -0,0 +1,282 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth, optionalAuth } from "@/lib/auth/middleware"; +import { + getListingById, + updateListing, + deleteListing, +} from "@/lib/db/queries/listings"; +import { updateListingSchema } from "@/lib/validation-schemas"; + +/** + * GET /api/listings/[id] + * Busca anúncio por ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + 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 listing = await getListingById(listingId); + + if (!listing) { + return NextResponse.json( + { + success: false, + error: "Anúncio não encontrado", + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + success: true, + listing, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar 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 buscar anúncio", + }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/listings/[id] + * Atualiza anúncio (apenas dono) + */ +export async function PATCH( + 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 editar este anúncio", + }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validação com Zod + const validationResult = updateListingSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Atualizar anúncio + const updatedListing = await updateListing(listingId, validatedData); + + if (!updatedListing) { + return NextResponse.json( + { + success: false, + error: "Erro ao atualizar anúncio", + }, + { status: 500 } + ); + } + + secureLogger.info("✅ Anúncio atualizado com sucesso", { + listingId, + userId: auth.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + listing: updatedListing, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao atualizar 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 atualizar anúncio", + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/listings/[id] + * Cancela anúncio (apenas dono) + */ +export async function DELETE( + 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 cancelar este anúncio", + }, + { status: 403 } + ); + } + + // Cancelar anúncio (soft delete) + const deleted = await deleteListing(listingId); + + if (!deleted) { + return NextResponse.json( + { + success: false, + error: "Erro ao cancelar anúncio", + }, + { status: 500 } + ); + } + + secureLogger.info("✅ Anúncio cancelado com sucesso", { + listingId, + userId: auth.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + message: "Anúncio cancelado com sucesso", + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao cancelar 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 cancelar anúncio", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/listings/[id]/views/route.ts b/app/api/listings/[id]/views/route.ts new file mode 100644 index 0000000..a2afaa6 --- /dev/null +++ b/app/api/listings/[id]/views/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { incrementViews } from "@/lib/db/queries/listings"; + +/** + * POST /api/listings/[id]/views + * Incrementa contador de views de um anúncio + * Não requer autenticação (público) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + 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 } + ); + } + + // Incrementar views + await incrementViews(listingId); + + return NextResponse.json( + { + success: true, + message: "View incrementada", + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao incrementar views", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao incrementar views", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts new file mode 100644 index 0000000..f3bea85 --- /dev/null +++ b/app/api/listings/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; +import { createListingSchema } from "@/lib/validation-schemas"; +import { requireAuth, optionalAuth } from "@/lib/auth/middleware"; +import { + getListings, + createListing, + getActiveListings, +} from "@/lib/db/queries/listings"; +import { isServerActive } from "@/lib/db/queries/servers"; + +/** + * GET /api/listings + * Lista anúncios com filtros opcionais + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + + const filters = { + status: searchParams.get("status") as + | "active" + | "sold" + | "cancelled" + | "in_escrow" + | "expired" + | undefined, + server_name: searchParams.get("server_name") || undefined, + item_id: searchParams.get("item_id") + ? parseInt(searchParams.get("item_id")!, 10) + : undefined, + seller_id: searchParams.get("seller_id") + ? parseInt(searchParams.get("seller_id")!, 10) + : undefined, + is_auction: + searchParams.get("is_auction") === "true" + ? true + : searchParams.get("is_auction") === "false" + ? false + : undefined, + search: searchParams.get("search") || 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", + }; + + // Se não especificar status, usar apenas ativos por padrão + const listingsData = filters.status + ? await getListings(filters) + : await getActiveListings(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", { + 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 } + ); + } +} + +/** + * POST /api/listings + * Cria um novo anúncio (requer autenticação) + */ +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/listings"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + // Autenticação obrigatória + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + const body = await request.json(); + + secureLogger.info("📥 POST /api/listings - Recebido", { + userId: auth.userId, + ip, + timestamp: new Date().toISOString(), + }); + + // Validação com Zod + const validationResult = createListingSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Validar se o servidor existe e está ativo + const serverActive = await isServerActive(validatedData.server_name); + if (!serverActive) { + return NextResponse.json( + { + success: false, + error: `Servidor "${validatedData.server_name}" não existe ou não está ativo`, + }, + { status: 400 } + ); + } + + // Criar anúncio + const listing = await createListing({ + seller_id: auth.userId, + item_id: validatedData.item_id, + item_name: validatedData.item_name, + quantity: validatedData.quantity, + server_name: validatedData.server_name, + price_pix: validatedData.price_pix ?? undefined, + price_tibia_coins: validatedData.price_tibia_coins ?? undefined, + price_gold_coins: validatedData.price_gold_coins ?? undefined, + is_auction: validatedData.is_auction, + auction_ends_at: validatedData.auction_ends_at ?? undefined, + description: validatedData.description ?? undefined, + }); + + secureLogger.info("✅ Anúncio criado com sucesso", { + listingId: listing.listing_id, + userId: auth.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + listing, + }, + { status: 201 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao criar 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 criar anúncio", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/servers/route.ts b/app/api/servers/route.ts new file mode 100644 index 0000000..b7c4e9d --- /dev/null +++ b/app/api/servers/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getActiveServers } from "@/lib/db/queries/servers"; +import { secureLogger } from "@/lib/secure-logger"; + +/** + * GET /api/servers + * Lista todos os servidores ativos disponíveis + */ +export async function GET(request: NextRequest) { + try { + const servers = await getActiveServers(); + + return NextResponse.json( + { + success: true, + servers: servers.map((server) => ({ + serverId: server.server_id, + serverName: server.server_name, + serverType: server.server_type, + location: server.location, + isActive: server.is_active, + stats: { + totalListings: server.total_listings, + totalUsers: server.total_users, + totalSubscribers: server.total_subscribers, + totalTransactions: server.total_transactions, + totalVolume: Number(server.total_volume), + averageListingPrice: Number(server.average_listing_price), + lastActivityAt: server.last_activity_at, + }, + })), + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar servidores", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao buscar servidores", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..37c11ad --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { getUserById } from "@/lib/db/queries/users"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const userId = parseInt(id, 10); + + if (isNaN(userId)) { + return NextResponse.json( + { + success: false, + error: "ID de usuário inválido", + }, + { status: 400 } + ); + } + + const user = await getUserById(userId); + + if (!user) { + return NextResponse.json( + { + success: false, + error: "Usuário não encontrado", + }, + { status: 404 } + ); + } + + // Retornar apenas dados públicos (sem informações sensíveis) + return NextResponse.json( + { + success: true, + user: { + userId: user.user_id, + username: user.username, + firstName: user.first_name, + lastName: user.last_name, + accountLevel: user.account_level, + hasDonated: user.has_donated, + createdAt: user.created_at, + }, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar perfil público", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/users/characters/[id]/route.ts b/app/api/users/characters/[id]/route.ts new file mode 100644 index 0000000..ed63b7e --- /dev/null +++ b/app/api/users/characters/[id]/route.ts @@ -0,0 +1,205 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { + updateCharacter, + deleteCharacter, + getCharacterById, +} from "@/lib/db/queries/characters"; +import { characterSchema } from "@/lib/validation-schemas"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + const { id } = await params; + const characterId = parseInt(id, 10); + + if (isNaN(characterId)) { + return NextResponse.json( + { + success: false, + error: "ID de personagem inválido", + }, + { status: 400 } + ); + } + + const body = await request.json(); + + // Validação com Zod + const validationResult = characterSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Atualizar personagem (verifica ownership internamente) + const updatedCharacter = await updateCharacter( + characterId, + auth.userId, + { + character_name: validatedData.characterName, + server_name: validatedData.serverName, + is_main: validatedData.isMain, + } + ); + + secureLogger.info("✅ Personagem atualizado com sucesso", { + userId: auth.userId, + characterId: updatedCharacter.character_id, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + character: { + characterId: updatedCharacter.character_id, + characterName: updatedCharacter.character_name, + serverName: updatedCharacter.server_name, + isMain: updatedCharacter.is_main, + verified: updatedCharacter.verified, + createdAt: updatedCharacter.created_at, + }, + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + // Verificar se é erro de permissão + if ( + error instanceof Error && + error.message.includes("não tem permissão") + ) { + return NextResponse.json( + { + success: false, + error: error.message, + }, + { status: 403 } + ); + } + + secureLogger.error("❌ Erro ao atualizar personagem", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + const { id } = await params; + const characterId = parseInt(id, 10); + + if (isNaN(characterId)) { + return NextResponse.json( + { + success: false, + error: "ID de personagem inválido", + }, + { status: 400 } + ); + } + + // Remover personagem (verifica ownership internamente) + await deleteCharacter(characterId, auth.userId); + + secureLogger.info("✅ Personagem removido com sucesso", { + userId: auth.userId, + characterId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + // Verificar se é erro de permissão + if ( + error instanceof Error && + error.message.includes("não tem permissão") + ) { + return NextResponse.json( + { + success: false, + error: error.message, + }, + { status: 403 } + ); + } + + secureLogger.error("❌ Erro ao remover personagem", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/users/characters/route.ts b/app/api/users/characters/route.ts new file mode 100644 index 0000000..11c4655 --- /dev/null +++ b/app/api/users/characters/route.ts @@ -0,0 +1,150 @@ +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 { characterSchema } from "@/lib/validation-schemas"; + +export async function GET(request: NextRequest) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + // Listar personagens do usuário + const characters = await getUserCharacters(auth.userId); + + return NextResponse.json( + { + success: true, + characters: characters.map((char) => ({ + characterId: char.character_id, + characterName: char.character_name, + serverName: char.server_name, + isMain: char.is_main, + verified: char.verified, + createdAt: char.created_at, + })), + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + secureLogger.error("❌ Erro ao listar personagens", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + const body = await request.json(); + + // Validação com Zod + const validationResult = characterSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Criar personagem + const newCharacter = await createCharacter(auth.userId, { + character_name: validatedData.characterName, + server_name: validatedData.serverName, + is_main: validatedData.isMain, + }); + + secureLogger.info("✅ Personagem criado com sucesso", { + userId: auth.userId, + characterId: newCharacter.character_id, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + character: { + characterId: newCharacter.character_id, + characterName: newCharacter.character_name, + serverName: newCharacter.server_name, + isMain: newCharacter.is_main, + verified: newCharacter.verified, + createdAt: newCharacter.created_at, + }, + }, + { status: 201 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + // Verificar se é erro de constraint (personagem duplicado) + if (error instanceof Error && error.message.includes("UNIQUE")) { + return NextResponse.json( + { + success: false, + error: "Este personagem já está cadastrado neste servidor", + }, + { status: 409 } + ); + } + + secureLogger.error("❌ Erro ao criar personagem", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts new file mode 100644 index 0000000..e2765d2 --- /dev/null +++ b/app/api/users/me/route.ts @@ -0,0 +1,179 @@ +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 { updateUserSchema } from "@/lib/validation-schemas"; + +export async function GET(request: NextRequest) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + // Buscar dados do usuário + const user = await getUserById(auth.userId); + + if (!user) { + return NextResponse.json( + { + success: false, + error: "Usuário não encontrado", + }, + { status: 404 } + ); + } + + // Buscar personagens do usuário + const characters = await getUserCharacters(auth.userId); + + return NextResponse.json( + { + success: true, + user: { + userId: user.user_id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + phone: user.phone, + cpf: user.cpf, + accountLevel: user.account_level, + hasDonated: user.has_donated, + whatsappNotifications: user.whatsapp_notifications, + createdAt: user.created_at, + updatedAt: user.updated_at, + lastLogin: user.last_login, + isActive: user.is_active, + isVerified: user.is_verified, + }, + characters: characters.map((char) => ({ + characterId: char.character_id, + characterName: char.character_name, + serverName: char.server_name, + isMain: char.is_main, + verified: char.verified, + createdAt: char.created_at, + })), + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + secureLogger.error("❌ Erro ao buscar perfil do usuário", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + try { + // Middleware de autenticação + const authResult = await requireAuth(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const auth = authResult; + + const body = await request.json(); + + // Validação com Zod + const validationResult = updateUserSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Atualizar usuário + const updatedUser = await updateUser(auth.userId, { + first_name: validatedData.firstName, + last_name: validatedData.lastName, + phone: validatedData.phone, + whatsapp_notifications: validatedData.whatsappNotifications, + }); + + secureLogger.info("✅ Perfil atualizado com sucesso", { + userId: auth.userId, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: true, + user: { + userId: updatedUser.user_id, + username: updatedUser.username, + email: updatedUser.email, + firstName: updatedUser.first_name, + lastName: updatedUser.last_name, + phone: updatedUser.phone, + cpf: updatedUser.cpf, + accountLevel: updatedUser.account_level, + hasDonated: updatedUser.has_donated, + whatsappNotifications: updatedUser.whatsapp_notifications, + createdAt: updatedUser.created_at, + updatedAt: updatedUser.updated_at, + lastLogin: updatedUser.last_login, + isActive: updatedUser.is_active, + isVerified: updatedUser.is_verified, + }, + }, + { status: 200 } + ); + } catch (error) { + // Se for NextResponse (erro de autenticação), re-lançar + if (error instanceof NextResponse) { + return error; + } + + secureLogger.error("❌ Erro ao atualizar perfil", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} + diff --git a/components/Header.tsx b/components/Header.tsx index 2c0b66d..e443d55 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -11,13 +11,37 @@ import { WordRotate } from "@/components/ui/word-rotate"; import { ThemeToggle } from "./theme-toggle"; import { LanguageSwitcher } from "./language-switcher"; import { MobileMenu } from "./mobile-menu"; +import { LoginDialog } from "./auth/login-dialog"; +import { RegisterDialog } from "./auth/register-dialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import { useTranslations, useLocale } from "next-intl"; +import { useSession } from "@/lib/hooks/use-session"; import Link from "next/link"; +import { useState, useEffect } from "react"; +import { LogOut, User } from "lucide-react"; export default function Header() { const t = useTranslations("header.nav"); const locale = useLocale(); + const { user, isAuthenticated, isLoading, logout } = useSession(); + const [loginDialogOpen, setLoginDialogOpen] = useState(false); + const [registerDialogOpen, setRegisterDialogOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + // Evitar hidratação mismatch + useEffect(() => { + setMounted(true); + }, []); const links = [ { href: `/${locale}/library`, label: "Library" }, @@ -27,6 +51,10 @@ export default function Header() { { href: `/${locale}/apoiar`, label: t("support"), highlight: true }, ]; + const handleLogout = async () => { + await logout(); + }; + return (
@@ -67,6 +95,66 @@ export default function Header() {
+ + {/* Auth Section */} + {!mounted ? ( + // Placeholder durante SSR para evitar mismatch +
+ ) : isLoading ? ( +
+ ) : isAuthenticated && user ? ( + + + + + + +
+

+ {user.username} +

+

+ {user.email} +

+
+
+ + + + + Perfil + + + + + + Sair + +
+
+ ) : ( +
+ + +
+ )}
{/* Mobile Menu */} @@ -74,6 +162,12 @@ export default function Header() {
+ + {/* Login Dialog */} + + + {/* Register Dialog */} +
); } diff --git a/components/auth/login-dialog.tsx b/components/auth/login-dialog.tsx new file mode 100644 index 0000000..30a3cb6 --- /dev/null +++ b/components/auth/login-dialog.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useSession } from "@/lib/hooks/use-session"; + +interface LoginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function LoginDialog({ open, onOpenChange }: LoginDialogProps) { + const [emailOrUsername, setEmailOrUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { refreshSession } = useSession(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + emailOrUsername, + password, + }), + }); + + const data = await response.json(); + + if (data.success) { + // Atualizar sessão + await refreshSession(); + // Fechar diálogo + onOpenChange(false); + // Limpar formulário + setEmailOrUsername(""); + setPassword(""); + // Recarregar página para atualizar estado + window.location.reload(); + } else { + setError(data.error || "Erro ao fazer login"); + } + } catch (err) { + setError("Erro ao conectar com o servidor"); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Entrar + + Faça login para acessar sua conta + + + +
+
+ + setEmailOrUsername(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} + diff --git a/components/auth/register-dialog.tsx b/components/auth/register-dialog.tsx new file mode 100644 index 0000000..38a7a2a --- /dev/null +++ b/components/auth/register-dialog.tsx @@ -0,0 +1,635 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { useSession } from "@/lib/hooks/use-session"; +import { useServers } from "@/lib/hooks/use-servers"; +import { useTranslations } from "next-intl"; +import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters"; +import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react"; + +interface RegisterDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface ServerSelection { + serverName: string; + characterName: string; + isMain: boolean; +} + +export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) { + const t = useTranslations("auth.register"); + const { refreshSession } = useSession(); + const { servers, isLoading: serversLoading, error: serversError } = useServers(); + + // Form fields + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [phone, setPhone] = useState(""); + const [selectedCountry, setSelectedCountry] = useState(DEFAULT_COUNTRY); + const [countrySearch, setCountrySearch] = useState(""); + + // Servers selection + const [selectedServers, setSelectedServers] = useState([]); + const [serverSearch, setServerSearch] = useState(""); + const [showServerDropdown, setShowServerDropdown] = useState(false); + + // UI state + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [passwordStrength, setPasswordStrength] = useState<"weak" | "medium" | "strong">("weak"); + + // Filtered servers based on search + const filteredServers = useMemo(() => { + if (!serverSearch.trim()) return servers; + const searchLower = serverSearch.toLowerCase(); + return servers.filter( + (s) => + s.serverName.toLowerCase().includes(searchLower) && + !selectedServers.some((sel) => sel.serverName === s.serverName) + ); + }, [serverSearch, servers, selectedServers]); + + // Update password strength + useEffect(() => { + if (password) { + setPasswordStrength(checkPasswordStrength(password)); + } else { + setPasswordStrength("weak"); + } + }, [password]); + + // Reset form when dialog closes + useEffect(() => { + if (!open) { + setUsername(""); + setEmail(""); + setPassword(""); + setConfirmPassword(""); + setFirstName(""); + setLastName(""); + setPhone(""); + setSelectedCountry(DEFAULT_COUNTRY); + setCountrySearch(""); + setSelectedServers([]); + setServerSearch(""); + setError(null); + setFieldErrors({}); + setPasswordStrength("weak"); + } + }, [open]); + + const addServer = (serverName: string) => { + if (selectedServers.some((s) => s.serverName === serverName)) { + return; + } + + const isFirst = selectedServers.length === 0; + setSelectedServers([ + ...selectedServers, + { + serverName, + characterName: "", + isMain: isFirst, + }, + ]); + setServerSearch(""); + setShowServerDropdown(false); + }; + + const removeServer = (index: number) => { + const newServers = selectedServers.filter((_, i) => i !== index); + // If we removed the main character and there are still servers, make first one main + if (selectedServers[index].isMain && newServers.length > 0) { + newServers[0].isMain = true; + } + setSelectedServers(newServers); + }; + + const updateServerCharacter = (index: number, characterName: string) => { + const newServers = [...selectedServers]; + newServers[index].characterName = characterName; + setSelectedServers(newServers); + }; + + const setMainServer = (index: number) => { + const newServers = selectedServers.map((s, i) => ({ + ...s, + isMain: i === index, + })); + setSelectedServers(newServers); + }; + + const handlePhoneChange = (value: string) => { + const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code); + setPhone(formatted); + }; + + // Filtrar países para busca + const filteredCountries = useMemo(() => { + if (!countrySearch.trim()) return COUNTRIES; + const searchLower = countrySearch.toLowerCase(); + return COUNTRIES.filter( + (c) => + c.name.toLowerCase().includes(searchLower) || + c.dialCode.includes(searchLower) || + c.code.toLowerCase().includes(searchLower) + ); + }, [countrySearch]); + + const validateForm = (): boolean => { + const errors: Record = {}; + + if (!username.trim()) { + errors.username = "Username é obrigatório"; + } else if (username.length < 3 || username.length > 50) { + errors.username = "Username deve ter entre 3 e 50 caracteres"; + } + + if (!email.trim()) { + errors.email = "Email é obrigatório"; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.email = "Email inválido"; + } + + const passwordValidation = validatePasswordRequirements(password); + if (!password) { + errors.password = "Senha é obrigatória"; + } else if (!passwordValidation.isValid) { + errors.password = passwordValidation.errors[0]; + } + + if (password !== confirmPassword) { + errors.confirmPassword = t("errors.passwordMismatch"); + } + + if (!firstName.trim()) { + errors.firstName = "Nome é obrigatório"; + } + + if (!lastName.trim()) { + errors.lastName = "Sobrenome é obrigatório"; + } + + if (selectedServers.length === 0) { + errors.servers = t("errors.noServers"); + } + + selectedServers.forEach((server, index) => { + if (!server.characterName.trim()) { + errors[`server_${index}`] = t("errors.characterNameRequired"); + } + }); + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setFieldErrors({}); + + if (!validateForm()) { + return; + } + + setIsLoading(true); + + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + username: username.trim().toLowerCase(), + email: email.trim().toLowerCase(), + password, + firstName: firstName.trim(), + lastName: lastName.trim(), + phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined, + servers: selectedServers.map((s) => ({ + serverName: s.serverName, + characterName: s.characterName.trim(), + isMain: s.isMain, + })), + }), + }); + + // Verificar se a resposta é JSON antes de fazer parse + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + const text = await response.text(); + console.error("Resposta não é JSON do registro:", text.substring(0, 200)); + setError("Erro: resposta inválida do servidor"); + setIsLoading(false); + return; + } + + const data = await response.json(); + + if (data.success) { + await refreshSession(); + onOpenChange(false); + window.location.reload(); + } else { + if (data.details) { + // Field-specific errors + const detailsErrors: Record = {}; + data.details.forEach((detail: { field: string; message: string }) => { + detailsErrors[detail.field] = detail.message; + }); + setFieldErrors(detailsErrors); + } else { + setError(data.error || "Erro ao criar conta"); + } + } + } catch (err) { + setError("Erro ao conectar com o servidor"); + } finally { + setIsLoading(false); + } + }; + + const getPasswordStrengthColor = () => { + switch (passwordStrength) { + case "weak": + return "bg-red-500"; + case "medium": + return "bg-yellow-500"; + case "strong": + return "bg-green-500"; + } + }; + + return ( + + + + {t("title")} + {t("description")} + + +
+ {/* Credentials Section */} +
+

Credenciais

+ +
+ + setUsername(e.target.value)} + disabled={isLoading} + className={fieldErrors.username ? "border-destructive" : ""} + /> + {fieldErrors.username && ( +

{fieldErrors.username}

+ )} +
+ +
+ + setEmail(e.target.value)} + disabled={isLoading} + className={fieldErrors.email ? "border-destructive" : ""} + /> + {fieldErrors.email && ( +

{fieldErrors.email}

+ )} +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + className={fieldErrors.password ? "border-destructive" : ""} + /> + {password && ( +
+
+
+
+
+ + {t(`passwordStrength.${passwordStrength}`)} + +
+
+ )} + {fieldErrors.password && ( +

{fieldErrors.password}

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isLoading} + className={fieldErrors.confirmPassword ? "border-destructive" : ""} + /> + {fieldErrors.confirmPassword && ( +

{fieldErrors.confirmPassword}

+ )} +
+
+ + {/* Personal Info Section */} +
+

Dados Pessoais

+ +
+
+ + setFirstName(e.target.value)} + disabled={isLoading} + className={fieldErrors.firstName ? "border-destructive" : ""} + /> + {fieldErrors.firstName && ( +

{fieldErrors.firstName}

+ )} +
+ +
+ + setLastName(e.target.value)} + disabled={isLoading} + className={fieldErrors.lastName ? "border-destructive" : ""} + /> + {fieldErrors.lastName && ( +

{fieldErrors.lastName}

+ )} +
+
+ +
+ +
+ {/* Seletor de País */} + + + + + +
+ setCountrySearch(e.target.value)} + className="mb-2" + onClick={(e) => e.stopPropagation()} + /> +
+ {filteredCountries.map((country) => ( + { + setSelectedCountry(country); + setCountrySearch(""); + // Reformatar telefone com novo país + if (phone) { + const unformatted = unformatPhone(phone); + const reformatted = formatInternationalPhone( + unformatted, + country.dialCode, + country.code + ); + setPhone(reformatted); + } + }} + className="cursor-pointer" + > + + {country.flag} + {country.name} + {country.dialCode} + + + ))} +
+
+ + {/* Campo de Telefone */} + handlePhoneChange(e.target.value)} + disabled={isLoading} + maxLength={20} + className="flex-1" + /> +
+

{t("phoneHint")}

+ {fieldErrors.phone && ( +

{fieldErrors.phone}

+ )} +
+
+ + {/* Servers Section */} +
+
+
+

{t("servers")}

+

{t("serversDescription")}

+
+
+ + {/* Server Search/Add */} +
+
+
+ { + setServerSearch(e.target.value); + setShowServerDropdown(true); + }} + onFocus={() => setShowServerDropdown(true)} + disabled={isLoading || serversLoading} + /> + {showServerDropdown && filteredServers.length > 0 && ( +
+ {filteredServers.slice(0, 10).map((server) => ( + + ))} +
+ )} +
+
+
+ + {/* Selected Servers */} + {selectedServers.length > 0 && ( +
+ {selectedServers.map((server, index) => ( +
+
+
+ {server.serverName} + {server.isMain && ( + + {t("mainCharacter")} + + )} +
+
+ {!server.isMain && ( + + )} + +
+
+
+ + updateServerCharacter(index, e.target.value)} + disabled={isLoading} + className={fieldErrors[`server_${index}`] ? "border-destructive" : ""} + /> + {fieldErrors[`server_${index}`] && ( +

+ {fieldErrors[`server_${index}`]} +

+ )} +
+
+ ))} +
+ )} + + {fieldErrors.servers && ( +

{fieldErrors.servers}

+ )} + {serversError && ( +

+ Erro ao carregar servidores: {serversError} +

+ )} +
+ + {error && ( +
+ {error} +
+ )} + + + + +
+ ); +} + diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index 74bb9cd..0f2a287 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { Moon, Sun, Monitor } from "lucide-react"; import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,22 @@ import { useTranslations } from "next-intl"; export function ThemeToggle() { const { setTheme, theme } = useTheme(); const t = useTranslations("header.theme"); + const [mounted, setMounted] = useState(false); + + // Evitar hidratação mismatch + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + // Renderizar um placeholder durante SSR para manter layout consistente + return ( + + ); + } return ( diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..cee0d4c --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; + diff --git a/lib/auth/jwt.ts b/lib/auth/jwt.ts new file mode 100644 index 0000000..907b307 --- /dev/null +++ b/lib/auth/jwt.ts @@ -0,0 +1,133 @@ +/** + * Módulo de geração e validação de tokens JWT + * Access tokens (15min) e Refresh tokens (7 dias) + */ + +import jwt from "jsonwebtoken"; + +interface TokenPayload { + userId: number; + username: string; + type: "access" | "refresh"; +} + +/** + * Obtém o secret do JWT (lazy loading) + */ +function getJWTSecret(): string { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET não está definido nas variáveis de ambiente"); + } + return secret; +} + +/** + * Obtém o refresh secret do JWT (lazy loading) + */ +function getJWTRefreshSecret(): string { + const secret = process.env.JWT_REFRESH_SECRET; + if (!secret) { + throw new Error( + "JWT_REFRESH_SECRET não está definido nas variáveis de ambiente" + ); + } + return secret; +} + +// Tempos de expiração +const ACCESS_TOKEN_EXPIRY = "15m"; // 15 minutos +const REFRESH_TOKEN_EXPIRY = "7d"; // 7 dias + +/** + * Gera access token JWT + * @param userId - ID do usuário + * @param username - Nome de usuário + * @returns Access token + */ +export function generateAccessToken( + userId: number, + username: string +): string { + const payload: TokenPayload = { + userId, + username, + type: "access", + }; + + return jwt.sign(payload, getJWTSecret(), { + expiresIn: ACCESS_TOKEN_EXPIRY, + }); +} + +/** + * Gera refresh token JWT + * @param userId - ID do usuário + * @param username - Nome de usuário + * @returns Refresh token + */ +export function generateRefreshToken( + userId: number, + username: string +): string { + const payload: TokenPayload = { + userId, + username, + type: "refresh", + }; + + return jwt.sign(payload, getJWTRefreshSecret(), { + expiresIn: REFRESH_TOKEN_EXPIRY, + }); +} + +/** + * Valida e decodifica access token + * @param token - Access token + * @returns Payload do token ou null se inválido + */ +export function verifyAccessToken(token: string): { + userId: number; + username: string; +} | null { + try { + const decoded = jwt.verify(token, getJWTSecret()) as TokenPayload; + + if (decoded.type !== "access") { + return null; + } + + return { + userId: decoded.userId, + username: decoded.username, + }; + } catch (error) { + return null; + } +} + +/** + * Valida e decodifica refresh token + * @param token - Refresh token + * @returns Payload do token ou null se inválido + */ +export function verifyRefreshToken(token: string): { + userId: number; + username: string; +} | null { + try { + const decoded = jwt.verify(token, getJWTRefreshSecret()) as TokenPayload; + + if (decoded.type !== "refresh") { + return null; + } + + return { + userId: decoded.userId, + username: decoded.username, + }; + } catch (error) { + return null; + } +} + diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts new file mode 100644 index 0000000..7c90851 --- /dev/null +++ b/lib/auth/middleware.ts @@ -0,0 +1,117 @@ +/** + * Middleware de autenticação + * Extrai e valida tokens JWT das requisições + */ + +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { verifyAccessToken } from "./jwt"; +import { getSession } from "@/lib/redis/session"; + +/** + * Extrai token do header Authorization + * @param request - Request do Next.js + * @returns Token ou null + */ +function extractToken(request: NextRequest): string | null { + const authHeader = request.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + + return authHeader.substring(7); // Remove "Bearer " +} + +/** + * Middleware que requer autenticação + * Retorna dados do usuário ou NextResponse com erro + * Suporta tanto Bearer token quanto cookie sessionId + * @param request - Request do Next.js + * @returns Dados do usuário autenticado ou NextResponse com erro + */ +export async function requireAuth( + request: NextRequest +): Promise< + | { userId: number; username: string } + | NextResponse<{ success: false; error: string }> +> { + // Tentar primeiro com Bearer token + const token = extractToken(request); + + if (token) { + const payload = verifyAccessToken(token); + + if (payload) { + return payload; + } + } + + // Se não tiver token no header, tentar com cookie + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (sessionId) { + const session = await getSession(sessionId); + + if (session) { + const tokenPayload = verifyAccessToken(session.accessToken); + + if (tokenPayload) { + return tokenPayload; + } + } + } + } catch (error) { + // Se der erro ao acessar cookies, continuar com o erro de token + } + + return NextResponse.json( + { + success: false, + error: "Token de autenticação não fornecido ou inválido", + }, + { status: 401 } + ); +} + +/** + * Middleware opcional de autenticação + * Retorna dados do usuário se autenticado, null caso contrário + * Não lança erro se não autenticado + * @param request - Request do Next.js + * @returns Dados do usuário ou null + */ +export async function optionalAuth( + request: NextRequest +): Promise<{ userId: number; username: string } | null> { + // Tentar primeiro com Bearer token + const token = extractToken(request); + + if (token) { + const payload = verifyAccessToken(token); + if (payload) { + return payload; + } + } + + // Tentar com cookie + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (sessionId) { + const session = await getSession(sessionId); + + if (session) { + const tokenPayload = verifyAccessToken(session.accessToken); + return tokenPayload; + } + } + } catch (error) { + // Ignorar erros de cookie + } + + return null; +} diff --git a/lib/auth/password.ts b/lib/auth/password.ts new file mode 100644 index 0000000..7390942 --- /dev/null +++ b/lib/auth/password.ts @@ -0,0 +1,43 @@ +/** + * Módulo de hash e verificação de senhas + * Usa bcrypt para hashing seguro + */ + +import bcrypt from "bcrypt"; + +const SALT_ROUNDS = 12; + +/** + * Gera hash de senha usando bcrypt + * @param password - Senha em texto plano + * @returns Hash da senha + */ +export async function hashPassword(password: string): Promise { + try { + const hash = await bcrypt.hash(password, SALT_ROUNDS); + return hash; + } catch (error) { + console.error("❌ Erro ao gerar hash de senha:", error); + throw new Error("Falha ao processar senha"); + } +} + +/** + * Compara senha em texto plano com hash + * @param password - Senha em texto plano + * @param hash - Hash armazenado + * @returns true se a senha corresponde, false caso contrário + */ +export async function comparePassword( + password: string, + hash: string +): Promise { + try { + const match = await bcrypt.compare(password, hash); + return match; + } catch (error) { + console.error("❌ Erro ao comparar senha:", error); + return false; + } +} + diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..fa2db09 --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,125 @@ +/** + * Módulo de conexão com PostgreSQL + * Gerencia pool de conexões e fornece helpers para queries + */ + +import { Pool, QueryResult } from "pg"; + +let pool: Pool | null = null; + +/** + * Valida variáveis de ambiente necessárias + */ +function validateEnvVars() { + if (!process.env.PG_DB) { + throw new Error( + "PG_DB não está definido. Configure a variável de ambiente PG_DB com a connection string do PostgreSQL." + ); + } +} + +/** + * Obtém ou cria o pool de conexões PostgreSQL + */ +export function getPool(): Pool { + if (!pool) { + validateEnvVars(); + + const connectionString = process.env.PG_DB; + + pool = new Pool({ + connectionString, + min: 2, // Mínimo de conexões no pool + max: 10, // Máximo de conexões no pool + idleTimeoutMillis: 30000, // Fecha conexões idle após 30s + connectionTimeoutMillis: 5000, // Timeout ao tentar conectar + }); + + // Event listeners para debug + pool.on("error", (err) => { + console.error("❌ Erro inesperado no pool de conexões PostgreSQL:", err); + }); + + pool.on("connect", () => { + console.log("✅ Nova conexão estabelecida com PostgreSQL"); + }); + } + + return pool; +} + +/** + * Executa uma query SQL com parâmetros + * @param text - SQL query com placeholders ($1, $2, etc) + * @param params - Array de parâmetros para a query + * @returns Resultado da query + */ +export async function query( + text: string, + params?: any[] +): Promise> { + const poolInstance = getPool(); + const start = Date.now(); + + try { + const result = await poolInstance.query(text, params); + const duration = Date.now() - start; + + // Log apenas em desenvolvimento ou queries lentas (>100ms) + if (process.env.NODE_ENV !== "production" || duration > 100) { + console.log("📊 Query executada", { + duration: `${duration}ms`, + query: text.substring(0, 100), + }); + } + + return result; + } catch (error) { + console.error("❌ Erro ao executar query:", { + query: text.substring(0, 100), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Health check do banco de dados + * @returns true se o banco está acessível, false caso contrário + */ +export async function healthCheck(): Promise { + try { + const poolInstance = getPool(); + const result = await poolInstance.query("SELECT NOW()"); + return result.rows.length > 0; + } catch (error) { + console.error("❌ Health check falhou:", error); + return false; + } +} + +/** + * Fecha o pool de conexões + * Útil para cleanup em testes ou shutdown graceful + */ +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + console.log("🔌 Pool de conexões PostgreSQL fechado"); + } +} + +// Cleanup ao encerrar o processo +if (typeof process !== "undefined") { + process.on("SIGINT", async () => { + await closePool(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + await closePool(); + process.exit(0); + }); +} + diff --git a/lib/db/migrations/001_users.sql b/lib/db/migrations/001_users.sql new file mode 100644 index 0000000..ccc5542 --- /dev/null +++ b/lib/db/migrations/001_users.sql @@ -0,0 +1,41 @@ +-- Migration 001: Tabelas de usuários e personagens +-- Criado em: 2025-01-27 + +-- Tabela de usuários +CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + phone VARCHAR(20), + cpf VARCHAR(14) UNIQUE NOT NULL, + account_level VARCHAR(20) DEFAULT 'free' CHECK (account_level IN ('free', 'bronze', 'silver', 'gold')), + has_donated BOOLEAN DEFAULT FALSE, + whatsapp_notifications BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + is_verified BOOLEAN DEFAULT FALSE +); + +-- Tabela de personagens dos usuários +CREATE TABLE IF NOT EXISTS user_characters ( + character_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + character_name VARCHAR(100) NOT NULL, + server_name VARCHAR(50) NOT NULL, + is_main BOOLEAN DEFAULT FALSE, + verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(character_name, server_name) +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_user_characters_user_id ON user_characters(user_id); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_cpf ON users(cpf); + diff --git a/lib/db/migrations/002_servers.sql b/lib/db/migrations/002_servers.sql new file mode 100644 index 0000000..3313629 --- /dev/null +++ b/lib/db/migrations/002_servers.sql @@ -0,0 +1,47 @@ +-- Migration 002: Tabela de servidores do Tibia +-- Criado em: 2025-01-27 + +-- Tornar CPF opcional na tabela users (para compatibilidade) +ALTER TABLE users ALTER COLUMN cpf DROP NOT NULL; + +-- Tabela de servidores +CREATE TABLE IF NOT EXISTS servers ( + server_id SERIAL PRIMARY KEY, + server_name VARCHAR(50) UNIQUE NOT NULL, + server_type VARCHAR(20) CHECK (server_type IN ('pvp', 'no-pvp', 'retro-pvp', 'hardcore-pvp')), + location VARCHAR(10) CHECK (location IN ('NA', 'SA', 'EU', 'OC')), + is_active BOOLEAN DEFAULT TRUE, + + -- Estatísticas de tracking + total_listings INTEGER DEFAULT 0, + total_users INTEGER DEFAULT 0, + total_subscribers INTEGER DEFAULT 0, + total_transactions INTEGER DEFAULT 0, + total_volume DECIMAL(15, 2) DEFAULT 0, + average_listing_price DECIMAL(10, 2) DEFAULT 0, + last_activity_at TIMESTAMP, + + -- Metadados + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_servers_name ON servers(server_name); +CREATE INDEX IF NOT EXISTS idx_servers_active ON servers(is_active); +CREATE INDEX IF NOT EXISTS idx_servers_type ON servers(server_type); + +-- Trigger para atualizar updated_at automaticamente +CREATE OR REPLACE FUNCTION update_servers_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_servers_updated_at + BEFORE UPDATE ON servers + FOR EACH ROW + EXECUTE FUNCTION update_servers_updated_at(); + diff --git a/lib/db/migrations/003_fix_servers_location.sql b/lib/db/migrations/003_fix_servers_location.sql new file mode 100644 index 0000000..53621c5 --- /dev/null +++ b/lib/db/migrations/003_fix_servers_location.sql @@ -0,0 +1,10 @@ +-- Migration 003: Corrigir constraint de location para incluir BR +-- Criado em: 2025-01-27 + +-- Remover constraint antigo +ALTER TABLE servers DROP CONSTRAINT IF EXISTS servers_location_check; + +-- Adicionar novo constraint que inclui BR +ALTER TABLE servers ADD CONSTRAINT servers_location_check + CHECK (location IN ('NA', 'SA', 'EU', 'OC', 'BR')); + diff --git a/lib/db/migrations/004_listings.sql b/lib/db/migrations/004_listings.sql new file mode 100644 index 0000000..7985c95 --- /dev/null +++ b/lib/db/migrations/004_listings.sql @@ -0,0 +1,54 @@ +-- Migration 004: Tabela de anúncios (listings) do marketplace +-- Criado em: 2025-01-27 + +-- Tabela de anúncios +CREATE TABLE IF NOT EXISTS listings ( + listing_id SERIAL PRIMARY KEY, + seller_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + item_id INTEGER NOT NULL, -- referência ao banco de itens do TibiaWiki + item_name VARCHAR(255) NOT NULL, + quantity INTEGER DEFAULT 1 CHECK (quantity > 0), + server_name VARCHAR(50) NOT NULL, + + -- Preços aceitos + price_pix DECIMAL(10, 2), + price_tibia_coins INTEGER, + price_gold_coins BIGINT, + + -- Leilão + is_auction BOOLEAN DEFAULT FALSE, + current_bid_amount DECIMAL(10, 2), + current_bidder_id INTEGER REFERENCES users(user_id), + auction_ends_at TIMESTAMP, + + -- Metadados + description TEXT, + listing_status VARCHAR(20) DEFAULT 'active' CHECK (listing_status IN ('active', 'sold', 'cancelled', 'in_escrow', 'expired')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + sold_at TIMESTAMP, + views_count INTEGER DEFAULT 0 +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_listings_status ON listings(listing_status); +CREATE INDEX IF NOT EXISTS idx_listings_item_id ON listings(item_id); +CREATE INDEX IF NOT EXISTS idx_listings_seller_id ON listings(seller_id); +CREATE INDEX IF NOT EXISTS idx_listings_server ON listings(server_name); +CREATE INDEX IF NOT EXISTS idx_listings_created_at ON listings(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_listings_auction ON listings(is_auction, auction_ends_at) WHERE is_auction = TRUE; + +-- Trigger para atualizar updated_at automaticamente +CREATE OR REPLACE FUNCTION update_listings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_listings_updated_at + BEFORE UPDATE ON listings + FOR EACH ROW + EXECUTE FUNCTION update_listings_updated_at(); + diff --git a/lib/db/migrations/005_bids.sql b/lib/db/migrations/005_bids.sql new file mode 100644 index 0000000..38f41bd --- /dev/null +++ b/lib/db/migrations/005_bids.sql @@ -0,0 +1,20 @@ +-- Migration 005: Tabela de lances em leilões +-- Criado em: 2025-01-27 + +-- Tabela de lances +CREATE TABLE IF NOT EXISTS bids ( + bid_id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE, + bidder_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + bid_amount DECIMAL(10, 2) NOT NULL, + bid_type VARCHAR(20) CHECK (bid_type IN ('pix', 'tibia_coins', 'gold_coins')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_bids_listing ON bids(listing_id); +CREATE INDEX IF NOT EXISTS idx_bids_bidder ON bids(bidder_id); +CREATE INDEX IF NOT EXISTS idx_bids_active ON bids(is_active) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_bids_amount ON bids(listing_id, bid_amount DESC) WHERE is_active = TRUE; + diff --git a/lib/db/migrations/006_transactions.sql b/lib/db/migrations/006_transactions.sql new file mode 100644 index 0000000..64730ea --- /dev/null +++ b/lib/db/migrations/006_transactions.sql @@ -0,0 +1,53 @@ +-- Migration 006: Tabela de transações do sistema de escrow +-- Criado em: 2025-01-27 + +-- Tabela de transações +CREATE TABLE IF NOT EXISTS transactions ( + transaction_id SERIAL PRIMARY KEY, + listing_id INTEGER REFERENCES listings(listing_id), + buyer_id INTEGER NOT NULL REFERENCES users(user_id), + seller_id INTEGER NOT NULL REFERENCES users(user_id), + + -- Detalhes do item + item_name VARCHAR(255) NOT NULL, + quantity INTEGER NOT NULL, + server_name VARCHAR(50) NOT NULL, + + -- Valores + amount DECIMAL(10, 2) NOT NULL, + payment_method VARCHAR(20) CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins')), + escrow_fee DECIMAL(10, 2), -- 2% da transação + + -- Status da transação + transaction_status VARCHAR(30) DEFAULT 'pending' CHECK (transaction_status IN ( + 'pending', -- aguardando depósito + 'deposited', -- comprador depositou + 'item_delivered', -- vendedor entregou + 'completed', -- pagamento liberado + 'disputed', -- em disputa + 'cancelled', -- cancelada + 'refunded' -- reembolsada + )), + + -- Datas importantes + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deposited_at TIMESTAMP, + delivered_at TIMESTAMP, + completed_at TIMESTAMP, + + -- Confirmações + buyer_confirmed BOOLEAN DEFAULT FALSE, + seller_confirmed BOOLEAN DEFAULT FALSE, + + -- Notas + buyer_notes TEXT, + seller_notes TEXT +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_transactions_buyer ON transactions(buyer_id); +CREATE INDEX IF NOT EXISTS idx_transactions_seller ON transactions(seller_id); +CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(transaction_status); +CREATE INDEX IF NOT EXISTS idx_transactions_listing ON transactions(listing_id); +CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at DESC); + diff --git a/lib/db/migrations/007_escrow_deposits.sql b/lib/db/migrations/007_escrow_deposits.sql new file mode 100644 index 0000000..1de820e --- /dev/null +++ b/lib/db/migrations/007_escrow_deposits.sql @@ -0,0 +1,22 @@ +-- Migration 007: Tabela de depósitos em escrow +-- Criado em: 2025-01-27 + +-- Tabela de depósitos em escrow +CREATE TABLE IF NOT EXISTS escrow_deposits ( + deposit_id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(transaction_id) ON DELETE CASCADE, + depositor_id INTEGER NOT NULL REFERENCES users(user_id), + amount DECIMAL(10, 2) NOT NULL, + payment_method VARCHAR(20), + payment_proof_url TEXT, + deposit_status VARCHAR(20) DEFAULT 'pending' CHECK (deposit_status IN ('pending', 'confirmed', 'released', 'refunded')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + confirmed_at TIMESTAMP, + released_at TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_escrow_deposits_transaction ON escrow_deposits(transaction_id); +CREATE INDEX IF NOT EXISTS idx_escrow_deposits_depositor ON escrow_deposits(depositor_id); +CREATE INDEX IF NOT EXISTS idx_escrow_deposits_status ON escrow_deposits(deposit_status); + diff --git a/lib/db/migrations/008_disputes.sql b/lib/db/migrations/008_disputes.sql new file mode 100644 index 0000000..22b1f75 --- /dev/null +++ b/lib/db/migrations/008_disputes.sql @@ -0,0 +1,21 @@ +-- Migration 008: Tabela de disputas +-- Criado em: 2025-01-27 + +-- Tabela de disputas +CREATE TABLE IF NOT EXISTS disputes ( + dispute_id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(transaction_id) ON DELETE CASCADE, + raised_by_user_id INTEGER NOT NULL REFERENCES users(user_id), + dispute_reason TEXT NOT NULL, + dispute_status VARCHAR(20) DEFAULT 'open' CHECK (dispute_status IN ('open', 'investigating', 'resolved', 'closed')), + resolution_notes TEXT, + resolved_by_admin_id INTEGER REFERENCES users(user_id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_disputes_transaction ON disputes(transaction_id); +CREATE INDEX IF NOT EXISTS idx_disputes_raised_by ON disputes(raised_by_user_id); +CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(dispute_status); + diff --git a/lib/db/migrations/009_item_price_history.sql b/lib/db/migrations/009_item_price_history.sql new file mode 100644 index 0000000..87ae7a6 --- /dev/null +++ b/lib/db/migrations/009_item_price_history.sql @@ -0,0 +1,21 @@ +-- Migration 009: Tabela de histórico de preços de itens +-- Criado em: 2025-01-27 + +-- Tabela de histórico de preços +CREATE TABLE IF NOT EXISTS item_price_history ( + price_history_id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL, + item_name VARCHAR(255) NOT NULL, + server_name VARCHAR(50), + price_amount DECIMAL(10, 2) NOT NULL, + price_type VARCHAR(20) CHECK (price_type IN ('pix', 'tibia_coins', 'gold_coins')), + transaction_id INTEGER REFERENCES transactions(transaction_id), + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_price_history_item ON item_price_history(item_id, recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_price_history_server ON item_price_history(server_name); +CREATE INDEX IF NOT EXISTS idx_price_history_transaction ON item_price_history(transaction_id); +CREATE INDEX IF NOT EXISTS idx_price_history_recorded_at ON item_price_history(recorded_at DESC); + diff --git a/lib/db/migrations/010_notifications.sql b/lib/db/migrations/010_notifications.sql new file mode 100644 index 0000000..daeedd0 --- /dev/null +++ b/lib/db/migrations/010_notifications.sql @@ -0,0 +1,23 @@ +-- Migration 010: Tabela de notificações +-- Criado em: 2025-01-27 + +-- Tabela de notificações +CREATE TABLE IF NOT EXISTS notifications ( + notification_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + notification_type VARCHAR(30) CHECK (notification_type IN ('bid', 'sale', 'purchase', 'escrow', 'dispute', 'system')), + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + related_id INTEGER, -- ID da transação/listing relacionado + is_read BOOLEAN DEFAULT FALSE, + send_whatsapp BOOLEAN DEFAULT FALSE, + whatsapp_sent BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read); +CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications(notification_type); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, is_read) WHERE is_read = FALSE; + diff --git a/lib/db/migrations/011_donations.sql b/lib/db/migrations/011_donations.sql new file mode 100644 index 0000000..37a62b0 --- /dev/null +++ b/lib/db/migrations/011_donations.sql @@ -0,0 +1,26 @@ +-- Migration 011: Tabela de doações +-- Criado em: 2025-01-27 + +-- Tabela de doações +CREATE TABLE IF NOT EXISTS donations ( + donation_id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(user_id), + donor_name VARCHAR(100), + donor_email VARCHAR(255), + amount DECIMAL(10, 2) NOT NULL, + donation_level VARCHAR(20) CHECK (donation_level IN ('coffee', 'snack', 'lunch', 'custom')), + payment_method VARCHAR(20) DEFAULT 'pix', + payment_status VARCHAR(20) DEFAULT 'pending' CHECK (payment_status IN ('pending', 'completed', 'failed', 'refunded')), + abacatepay_id VARCHAR(100), + message TEXT, + is_anonymous BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP +); + +-- Índices para performance +CREATE INDEX IF NOT EXISTS idx_donations_user ON donations(user_id); +CREATE INDEX IF NOT EXISTS idx_donations_status ON donations(payment_status); +CREATE INDEX IF NOT EXISTS idx_donations_abacatepay_id ON donations(abacatepay_id); +CREATE INDEX IF NOT EXISTS idx_donations_created_at ON donations(created_at DESC); + diff --git a/lib/db/migrations/run-migrations.ts b/lib/db/migrations/run-migrations.ts new file mode 100644 index 0000000..368aed7 --- /dev/null +++ b/lib/db/migrations/run-migrations.ts @@ -0,0 +1,113 @@ +/** + * Script de execução de migrations + * Executa todas as migrations SQL em ordem + */ + +// Carregar variáveis de ambiente +import { config } from "dotenv"; +import { resolve } from "path"; + +// Tentar carregar .env.local primeiro, depois .env +config({ path: resolve(process.cwd(), ".env.local") }); +config({ path: resolve(process.cwd(), ".env") }); + +import { readFileSync } from "fs"; +import { join } from "path"; +import { getPool, query, healthCheck } from "../index"; + +const MIGRATIONS_DIR = join(__dirname, "."); + +// Lista de migrations em ordem +const MIGRATIONS = [ + "001_users.sql", + "002_servers.sql", + "003_fix_servers_location.sql", + "004_listings.sql", + "005_bids.sql", + "006_transactions.sql", + "007_escrow_deposits.sql", + "008_disputes.sql", + "009_item_price_history.sql", + "010_notifications.sql", + "011_donations.sql", +]; + +async function runMigrations() { + console.log("🚀 Iniciando execução de migrations...\n"); + + // Verificar conexão com banco + console.log("🔍 Verificando conexão com PostgreSQL..."); + const isHealthy = await healthCheck(); + + if (!isHealthy) { + console.error("❌ Falha na conexão com PostgreSQL. Verifique a variável PG_DB."); + process.exit(1); + } + + console.log("✅ Conexão estabelecida com sucesso\n"); + + // Verificar tabela de controle de migrations (opcional) + try { + await query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + } catch (error) { + console.error("❌ Erro ao criar tabela de controle:", error); + process.exit(1); + } + + // Executar cada migration + for (const migrationFile of MIGRATIONS) { + const migrationPath = join(MIGRATIONS_DIR, migrationFile); + const version = migrationFile.replace(".sql", ""); + + try { + // Verificar se já foi executada + const checkResult = await query<{ version: string }>( + "SELECT version FROM schema_migrations WHERE version = $1", + [version] + ); + + if (checkResult.rows.length > 0) { + console.log(`⏭️ Migration ${version} já executada, pulando...`); + continue; + } + + console.log(`📝 Executando migration: ${migrationFile}...`); + + // Ler arquivo SQL + const sql = readFileSync(migrationPath, "utf-8"); + + // Executar migration + await query(sql); + + // Registrar como executada + await query( + "INSERT INTO schema_migrations (version) VALUES ($1)", + [version] + ); + + console.log(`✅ Migration ${version} executada com sucesso\n`); + } catch (error) { + console.error(`❌ Erro ao executar migration ${version}:`, error); + process.exit(1); + } + } + + console.log("🎉 Todas as migrations foram executadas com sucesso!"); + + // Fechar pool + const pool = getPool(); + await pool.end(); + process.exit(0); +} + +// Executar +runMigrations().catch((error) => { + console.error("❌ Erro fatal:", error); + process.exit(1); +}); + diff --git a/lib/db/queries/bids.ts b/lib/db/queries/bids.ts new file mode 100644 index 0000000..82e072d --- /dev/null +++ b/lib/db/queries/bids.ts @@ -0,0 +1,111 @@ +/** + * Queries de bids (lances em leilões) + * Operações para a tabela bids + */ + +import { query } from "../index"; + +export interface Bid { + bid_id: number; + listing_id: number; + bidder_id: number; + bid_amount: number; + bid_type: "pix" | "tibia_coins" | "gold_coins" | null; + created_at: Date; + is_active: boolean; +} + +export interface CreateBidData { + listing_id: number; + bidder_id: number; + bid_amount: number; + bid_type?: "pix" | "tibia_coins" | "gold_coins"; +} + +/** + * Cria um novo lance + */ +export async function createBid(data: CreateBidData): Promise { + const result = await query( + ` + INSERT INTO bids (listing_id, bidder_id, bid_amount, bid_type) + VALUES ($1, $2, $3, $4) + RETURNING * + `, + [data.listing_id, data.bidder_id, data.bid_amount, data.bid_type || null] + ); + + return result.rows[0]; +} + +/** + * Busca lances de um anúncio + */ +export async function getBidsByListing( + listingId: number, + activeOnly: boolean = true +): Promise { + let sql = ` + SELECT * FROM bids + WHERE listing_id = $1 + `; + + const params: any[] = [listingId]; + + if (activeOnly) { + sql += ` AND is_active = TRUE`; + } + + sql += ` ORDER BY bid_amount DESC, created_at DESC`; + + const result = await query(sql, params); + return result.rows; +} + +/** + * Busca o maior lance de um anúncio + */ +export async function getHighestBid(listingId: number): Promise { + const result = await query( + ` + SELECT * FROM bids + WHERE listing_id = $1 AND is_active = TRUE + ORDER BY bid_amount DESC, created_at DESC + LIMIT 1 + `, + [listingId] + ); + + return result.rows[0] || null; +} + +/** + * Cancela um lance (desativa) + */ +export async function cancelBid(bidId: number): Promise { + const result = await query( + ` + UPDATE bids + SET is_active = FALSE + WHERE bid_id = $1 + `, + [bidId] + ); + + return result.rowCount > 0; +} + +/** + * Busca lance por ID + */ +export async function getBidById(bidId: number): Promise { + const result = await query( + ` + SELECT * FROM bids WHERE bid_id = $1 + `, + [bidId] + ); + + return result.rows[0] || null; +} + diff --git a/lib/db/queries/characters.ts b/lib/db/queries/characters.ts new file mode 100644 index 0000000..9451c1c --- /dev/null +++ b/lib/db/queries/characters.ts @@ -0,0 +1,224 @@ +/** + * Queries de personagens + * CRUD completo para a tabela user_characters + */ + +import { query } from "../index"; + +export interface Character { + character_id: number; + user_id: number; + character_name: string; + server_name: string; + is_main: boolean; + verified: boolean; + created_at: Date; +} + +export interface CreateCharacterData { + character_name: string; + server_name: string; + is_main?: boolean; +} + +export interface UpdateCharacterData { + character_name?: string; + server_name?: string; + is_main?: boolean; +} + +/** + * Cria um novo personagem + * Se isMain=true, desmarca outros personagens como main + */ +export async function createCharacter( + userId: number, + data: CreateCharacterData +): Promise { + // Usar transação para garantir atomicidade + const { getPool } = await import("../index"); + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // Se este personagem será main, desmarcar outros + if (data.is_main) { + await client.query( + ` + UPDATE user_characters + SET is_main = FALSE + WHERE user_id = $1 + `, + [userId] + ); + } + + // Inserir novo personagem + const result = await client.query( + ` + INSERT INTO user_characters (user_id, character_name, server_name, is_main) + VALUES ($1, $2, $3, $4) + RETURNING * + `, + [userId, data.character_name, data.server_name, data.is_main || false] + ); + + await client.query("COMMIT"); + + return result.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +/** + * Lista todos os personagens de um usuário + */ +export async function getUserCharacters(userId: number): Promise { + const result = await query( + ` + SELECT * FROM user_characters + WHERE user_id = $1 + ORDER BY is_main DESC, created_at ASC + `, + [userId] + ); + + return result.rows; +} + +/** + * Busca personagem por ID + */ +export async function getCharacterById( + characterId: number +): Promise { + const result = await query( + ` + SELECT * FROM user_characters WHERE character_id = $1 + `, + [characterId] + ); + + return result.rows[0] || null; +} + +/** + * Atualiza personagem + * Verifica ownership e se isMain=true, desmarca outros + */ +export async function updateCharacter( + characterId: number, + userId: number, + data: UpdateCharacterData +): Promise { + // Verificar ownership primeiro + const existing = await getCharacterById(characterId); + if (!existing) { + throw new Error("Personagem não encontrado"); + } + + if (existing.user_id !== userId) { + throw new Error("Você não tem permissão para atualizar este personagem"); + } + + // Usar transação para garantir atomicidade + const { getPool } = await import("../index"); + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // Se este personagem será main, desmarcar outros + if (data.is_main) { + await client.query( + ` + UPDATE user_characters + SET is_main = FALSE + WHERE user_id = $1 AND character_id != $2 + `, + [userId, characterId] + ); + } + + // Construir query de update + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (data.character_name !== undefined) { + updates.push(`character_name = $${paramIndex++}`); + values.push(data.character_name); + } + + if (data.server_name !== undefined) { + updates.push(`server_name = $${paramIndex++}`); + values.push(data.server_name); + } + + if (data.is_main !== undefined) { + updates.push(`is_main = $${paramIndex++}`); + values.push(data.is_main); + } + + if (updates.length === 0) { + // Se não há updates, retorna personagem atual + await client.query("COMMIT"); + return existing; + } + + values.push(characterId); + + const result = await client.query( + ` + UPDATE user_characters + SET ${updates.join(", ")} + WHERE character_id = $${paramIndex} + RETURNING * + `, + values + ); + + await client.query("COMMIT"); + + return result.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +/** + * Remove personagem + * Verifica ownership antes de remover + */ +export async function deleteCharacter( + characterId: number, + userId: number +): Promise { + // Verificar ownership + const existing = await getCharacterById(characterId); + if (!existing) { + throw new Error("Personagem não encontrado"); + } + + if (existing.user_id !== userId) { + throw new Error("Você não tem permissão para remover este personagem"); + } + + await query( + ` + DELETE FROM user_characters WHERE character_id = $1 + `, + [characterId] + ); +} + diff --git a/lib/db/queries/listings.ts b/lib/db/queries/listings.ts new file mode 100644 index 0000000..89cbd5b --- /dev/null +++ b/lib/db/queries/listings.ts @@ -0,0 +1,338 @@ +/** + * Queries de listings (anúncios) + * CRUD completo para a tabela listings + */ + +import { query } from "../index"; + +export interface Listing { + listing_id: number; + seller_id: number; + item_id: number; + item_name: string; + quantity: number; + server_name: string; + price_pix: number | null; + price_tibia_coins: number | null; + price_gold_coins: number | null; + is_auction: boolean; + current_bid_amount: number | null; + current_bidder_id: number | null; + auction_ends_at: Date | null; + description: string | null; + listing_status: "active" | "sold" | "cancelled" | "in_escrow" | "expired"; + created_at: Date; + updated_at: Date; + sold_at: Date | null; + views_count: number; +} + +export interface CreateListingData { + seller_id: number; + item_id: number; + item_name: string; + quantity: number; + server_name: string; + price_pix?: number; + price_tibia_coins?: number; + price_gold_coins?: number; + is_auction?: boolean; + auction_ends_at?: Date; + description?: string; +} + +export interface UpdateListingData { + quantity?: number; + price_pix?: number; + price_tibia_coins?: number; + price_gold_coins?: number; + description?: string; + listing_status?: Listing["listing_status"]; + current_bid_amount?: number; + current_bidder_id?: number; +} + +export interface ListingFilters { + status?: Listing["listing_status"]; + server_name?: string; + item_id?: number; + seller_id?: number; + is_auction?: boolean; + search?: string; + page?: number; + pageSize?: number; + sortBy?: "created_at" | "price_pix" | "price_tibia_coins" | "price_gold_coins" | "views_count"; + sortOrder?: "ASC" | "DESC"; +} + +/** + * Cria um novo anúncio + */ +export async function createListing( + data: CreateListingData +): Promise { + const result = await query( + ` + INSERT INTO listings ( + seller_id, item_id, item_name, quantity, server_name, + price_pix, price_tibia_coins, price_gold_coins, + is_auction, auction_ends_at, description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `, + [ + data.seller_id, + data.item_id, + data.item_name, + data.quantity, + data.server_name, + data.price_pix || null, + data.price_tibia_coins || null, + data.price_gold_coins || null, + data.is_auction || false, + data.auction_ends_at || null, + data.description || null, + ] + ); + + return result.rows[0]; +} + +/** + * Busca anúncio por ID + */ +export async function getListingById(listingId: number): Promise { + const result = await query( + ` + SELECT * FROM listings WHERE listing_id = $1 + `, + [listingId] + ); + + return result.rows[0] || null; +} + +/** + * Lista anúncios com filtros e paginação + */ +export async function getListings( + filters: ListingFilters = {} +): Promise<{ listings: Listing[]; total: number }> { + const { + status, + server_name, + item_id, + seller_id, + is_auction, + search, + page = 1, + pageSize = 20, + sortBy = "created_at", + sortOrder = "DESC", + } = filters; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (status) { + conditions.push(`listing_status = $${paramIndex}`); + params.push(status); + paramIndex++; + } + + if (server_name) { + conditions.push(`server_name = $${paramIndex}`); + params.push(server_name); + paramIndex++; + } + + if (item_id) { + conditions.push(`item_id = $${paramIndex}`); + params.push(item_id); + paramIndex++; + } + + if (seller_id) { + conditions.push(`seller_id = $${paramIndex}`); + params.push(seller_id); + paramIndex++; + } + + if (is_auction !== undefined) { + conditions.push(`is_auction = $${paramIndex}`); + params.push(is_auction); + paramIndex++; + } + + if (search) { + conditions.push(`(item_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`); + params.push(`%${search}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // Contar total + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM listings ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].count, 10); + + // Validar sortBy + const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"]; + const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at"; + const sortDirection = sortOrder === "ASC" ? "ASC" : "DESC"; + + // Buscar listings + const offset = (page - 1) * pageSize; + const result = await query( + ` + SELECT * FROM listings + ${whereClause} + ORDER BY ${sortColumn} ${sortDirection} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `, + [...params, pageSize, offset] + ); + + return { + listings: result.rows, + total, + }; +} + +/** + * Atualiza um anúncio + */ +export async function updateListing( + listingId: number, + data: UpdateListingData +): Promise { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.quantity !== undefined) { + updates.push(`quantity = $${paramIndex}`); + params.push(data.quantity); + paramIndex++; + } + + if (data.price_pix !== undefined) { + updates.push(`price_pix = $${paramIndex}`); + params.push(data.price_pix); + paramIndex++; + } + + if (data.price_tibia_coins !== undefined) { + updates.push(`price_tibia_coins = $${paramIndex}`); + params.push(data.price_tibia_coins); + paramIndex++; + } + + if (data.price_gold_coins !== undefined) { + updates.push(`price_gold_coins = $${paramIndex}`); + params.push(data.price_gold_coins); + paramIndex++; + } + + if (data.description !== undefined) { + updates.push(`description = $${paramIndex}`); + params.push(data.description); + paramIndex++; + } + + if (data.listing_status !== undefined) { + updates.push(`listing_status = $${paramIndex}`); + params.push(data.listing_status); + paramIndex++; + } + + if (data.current_bid_amount !== undefined) { + updates.push(`current_bid_amount = $${paramIndex}`); + params.push(data.current_bid_amount); + paramIndex++; + } + + if (data.current_bidder_id !== undefined) { + updates.push(`current_bidder_id = $${paramIndex}`); + params.push(data.current_bidder_id); + paramIndex++; + } + + if (updates.length === 0) { + return getListingById(listingId); + } + + params.push(listingId); + const result = await query( + ` + UPDATE listings + SET ${updates.join(", ")} + WHERE listing_id = $${paramIndex} + RETURNING * + `, + params + ); + + return result.rows[0] || null; +} + +/** + * Cancela um anúncio (soft delete - muda status) + */ +export async function deleteListing(listingId: number): Promise { + const result = await query( + ` + UPDATE listings + SET listing_status = 'cancelled' + WHERE listing_id = $1 + `, + [listingId] + ); + + return result.rowCount > 0; +} + +/** + * Incrementa contador de views + */ +export async function incrementViews(listingId: number): Promise { + await query( + ` + UPDATE listings + SET views_count = views_count + 1 + WHERE listing_id = $1 + `, + [listingId] + ); +} + +/** + * Busca anúncios de um vendedor + */ +export async function getListingsBySeller( + sellerId: number, + filters: { status?: Listing["listing_status"]; page?: number; pageSize?: number } = {} +): Promise<{ listings: Listing[]; total: number }> { + return getListings({ + seller_id: sellerId, + ...filters, + }); +} + +/** + * Busca apenas anúncios ativos + */ +export async function getActiveListings( + filters: Omit = {} +): Promise<{ listings: Listing[]; total: number }> { + return getListings({ + ...filters, + status: "active", + }); +} + diff --git a/lib/db/queries/servers.ts b/lib/db/queries/servers.ts new file mode 100644 index 0000000..23c0de9 --- /dev/null +++ b/lib/db/queries/servers.ts @@ -0,0 +1,208 @@ +/** + * Queries de servidores + * CRUD completo para a tabela servers + */ + +import { query } from "../index"; + +export interface Server { + server_id: number; + server_name: string; + server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null; + location: "NA" | "SA" | "EU" | "OC" | "BR" | null; + is_active: boolean; + total_listings: number; + total_users: number; + total_subscribers: number; + total_transactions: number; + total_volume: number; + average_listing_price: number; + last_activity_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface ServerStats { + total_listings?: number; + total_users?: number; + total_subscribers?: number; + total_transactions?: number; + total_volume?: number; + average_listing_price?: number; + last_activity_at?: Date; +} + +/** + * Lista todos os servidores + */ +export async function getAllServers(): Promise { + const result = await query( + `SELECT * FROM servers ORDER BY server_name ASC` + ); + return result.rows; +} + +/** + * Lista apenas servidores ativos + */ +export async function getActiveServers(): Promise { + const result = await query( + `SELECT * FROM servers WHERE is_active = TRUE ORDER BY server_name ASC` + ); + return result.rows; +} + +/** + * Busca servidor por nome + */ +export async function getServerByName(serverName: string): Promise { + const result = await query( + `SELECT * FROM servers WHERE server_name = $1`, + [serverName] + ); + return result.rows[0] || null; +} + +/** + * Busca servidor por ID + */ +export async function getServerById(serverId: number): Promise { + const result = await query( + `SELECT * FROM servers WHERE server_id = $1`, + [serverId] + ); + return result.rows[0] || null; +} + +/** + * Verifica se servidor existe e está ativo + */ +export async function isServerActive(serverName: string): Promise { + const result = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM servers WHERE server_name = $1 AND is_active = TRUE`, + [serverName] + ); + return parseInt(result.rows[0].count) > 0; +} + +/** + * Incrementa estatísticas do servidor + */ +export async function incrementServerStats( + serverName: string, + stats: Partial<{ + total_listings: number; + total_users: number; + total_subscribers: number; + total_transactions: number; + total_volume: number; + }> +): Promise { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (stats.total_listings !== undefined) { + updates.push(`total_listings = total_listings + $${paramIndex++}`); + values.push(stats.total_listings); + } + if (stats.total_users !== undefined) { + updates.push(`total_users = total_users + $${paramIndex++}`); + values.push(stats.total_users); + } + if (stats.total_subscribers !== undefined) { + updates.push(`total_subscribers = total_subscribers + $${paramIndex++}`); + values.push(stats.total_subscribers); + } + if (stats.total_transactions !== undefined) { + updates.push(`total_transactions = total_transactions + $${paramIndex++}`); + values.push(stats.total_transactions); + } + if (stats.total_volume !== undefined) { + updates.push(`total_volume = total_volume + $${paramIndex++}`); + values.push(stats.total_volume); + } + + if (updates.length === 0) { + return; + } + + // Sempre atualizar last_activity_at + updates.push(`last_activity_at = CURRENT_TIMESTAMP`); + values.push(serverName); + + await query( + `UPDATE servers SET ${updates.join(", ")} WHERE server_name = $${paramIndex}`, + values + ); +} + +/** + * Atualiza estatísticas completas do servidor + */ +export async function updateServerStats( + serverName: string, + stats: ServerStats +): Promise { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (stats.total_listings !== undefined) { + updates.push(`total_listings = $${paramIndex++}`); + values.push(stats.total_listings); + } + if (stats.total_users !== undefined) { + updates.push(`total_users = $${paramIndex++}`); + values.push(stats.total_users); + } + if (stats.total_subscribers !== undefined) { + updates.push(`total_subscribers = $${paramIndex++}`); + values.push(stats.total_subscribers); + } + if (stats.total_transactions !== undefined) { + updates.push(`total_transactions = $${paramIndex++}`); + values.push(stats.total_transactions); + } + if (stats.total_volume !== undefined) { + updates.push(`total_volume = $${paramIndex++}`); + values.push(stats.total_volume); + } + if (stats.average_listing_price !== undefined) { + updates.push(`average_listing_price = $${paramIndex++}`); + values.push(stats.average_listing_price); + } + if (stats.last_activity_at !== undefined) { + updates.push(`last_activity_at = $${paramIndex++}`); + values.push(stats.last_activity_at); + } + + if (updates.length === 0) { + return; + } + + values.push(serverName); + + await query( + `UPDATE servers SET ${updates.join(", ")} WHERE server_name = $${paramIndex}`, + values + ); +} + +/** + * Incrementa total_users de múltiplos servidores + */ +export async function incrementUsersForServers(serverNames: string[]): Promise { + if (serverNames.length === 0) { + return; + } + + await query( + `UPDATE servers + SET total_users = total_users + 1, + last_activity_at = CURRENT_TIMESTAMP + WHERE server_name = ANY($1::text[])`, + [serverNames] + ); +} + diff --git a/lib/db/queries/transactions.ts b/lib/db/queries/transactions.ts new file mode 100644 index 0000000..25fe0fd --- /dev/null +++ b/lib/db/queries/transactions.ts @@ -0,0 +1,241 @@ +/** + * Queries de transactions (transações do sistema de escrow) + * Operações para a tabela transactions + */ + +import { query } from "../index"; + +export interface Transaction { + transaction_id: number; + listing_id: number | null; + buyer_id: number; + seller_id: number; + item_name: string; + quantity: number; + server_name: string; + amount: number; + payment_method: "pix" | "tibia_coins" | "gold_coins" | null; + escrow_fee: number | null; + transaction_status: + | "pending" + | "deposited" + | "item_delivered" + | "completed" + | "disputed" + | "cancelled" + | "refunded"; + created_at: Date; + deposited_at: Date | null; + delivered_at: Date | null; + completed_at: Date | null; + buyer_confirmed: boolean; + seller_confirmed: boolean; + buyer_notes: string | null; + seller_notes: string | null; +} + +export interface CreateTransactionData { + listing_id?: number; + buyer_id: number; + seller_id: number; + item_name: string; + quantity: number; + server_name: string; + amount: number; + payment_method: "pix" | "tibia_coins" | "gold_coins"; + escrow_fee?: number; +} + +export interface UpdateTransactionStatusData { + transaction_status: Transaction["transaction_status"]; + deposited_at?: Date; + delivered_at?: Date; + completed_at?: Date; + buyer_confirmed?: boolean; + seller_confirmed?: boolean; + buyer_notes?: string; + seller_notes?: string; +} + +/** + * Cria uma nova transação + */ +export async function createTransaction( + data: CreateTransactionData +): Promise { + // Calcular escrow_fee se não fornecido (2% do valor) + const escrowFee = data.escrow_fee ?? Number((data.amount * 0.02).toFixed(2)); + + const result = await query( + ` + INSERT INTO transactions ( + listing_id, buyer_id, seller_id, item_name, quantity, + server_name, amount, payment_method, escrow_fee + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `, + [ + data.listing_id || null, + data.buyer_id, + data.seller_id, + data.item_name, + data.quantity, + data.server_name, + data.amount, + data.payment_method, + escrowFee, + ] + ); + + return result.rows[0]; +} + +/** + * Busca transação por ID + */ +export async function getTransactionById( + transactionId: number +): Promise { + const result = await query( + ` + SELECT * FROM transactions WHERE transaction_id = $1 + `, + [transactionId] + ); + + return result.rows[0] || null; +} + +/** + * Atualiza status da transação + */ +export async function updateTransactionStatus( + transactionId: number, + data: UpdateTransactionStatusData +): Promise { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + updates.push(`transaction_status = $${paramIndex}`); + params.push(data.transaction_status); + paramIndex++; + + if (data.deposited_at !== undefined) { + updates.push(`deposited_at = $${paramIndex}`); + params.push(data.deposited_at); + paramIndex++; + } + + if (data.delivered_at !== undefined) { + updates.push(`delivered_at = $${paramIndex}`); + params.push(data.delivered_at); + paramIndex++; + } + + if (data.completed_at !== undefined) { + updates.push(`completed_at = $${paramIndex}`); + params.push(data.completed_at); + paramIndex++; + } + + if (data.buyer_confirmed !== undefined) { + updates.push(`buyer_confirmed = $${paramIndex}`); + params.push(data.buyer_confirmed); + paramIndex++; + } + + if (data.seller_confirmed !== undefined) { + updates.push(`seller_confirmed = $${paramIndex}`); + params.push(data.seller_confirmed); + paramIndex++; + } + + if (data.buyer_notes !== undefined) { + updates.push(`buyer_notes = $${paramIndex}`); + params.push(data.buyer_notes); + paramIndex++; + } + + if (data.seller_notes !== undefined) { + updates.push(`seller_notes = $${paramIndex}`); + params.push(data.seller_notes); + paramIndex++; + } + + params.push(transactionId); + const result = await query( + ` + UPDATE transactions + SET ${updates.join(", ")} + WHERE transaction_id = $${paramIndex} + RETURNING * + `, + params + ); + + return result.rows[0] || null; +} + +/** + * Busca transações de um usuário (comprador ou vendedor) + */ +export async function getTransactionsByUser( + userId: number, + filters: { + role?: "buyer" | "seller" | "both"; + status?: Transaction["transaction_status"]; + page?: number; + pageSize?: number; + } = {} +): Promise<{ transactions: Transaction[]; total: number }> { + const { role = "both", status, page = 1, pageSize = 20 } = filters; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (role === "buyer") { + conditions.push(`buyer_id = $${paramIndex}`); + } else if (role === "seller") { + conditions.push(`seller_id = $${paramIndex}`); + } else { + conditions.push(`(buyer_id = $${paramIndex} OR seller_id = $${paramIndex})`); + } + params.push(userId); + paramIndex++; + + if (status) { + conditions.push(`transaction_status = $${paramIndex}`); + params.push(status); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // Contar total + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM transactions ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].count, 10); + + // Buscar transações + const offset = (page - 1) * pageSize; + const result = await query( + ` + SELECT * FROM transactions + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `, + [...params, pageSize, offset] + ); + + return { + transactions: result.rows, + total, + }; +} + diff --git a/lib/db/queries/users.ts b/lib/db/queries/users.ts new file mode 100644 index 0000000..80e64b8 --- /dev/null +++ b/lib/db/queries/users.ts @@ -0,0 +1,213 @@ +/** + * Queries de usuários + * CRUD completo para a tabela users + */ + +import { query } from "../index"; + +export interface User { + user_id: number; + username: string; + email: string; + password_hash?: string; // Apenas para login, não retornar em outros lugares + first_name: string; + last_name: string; + phone: string | null; + cpf: string; + account_level: "free" | "bronze" | "silver" | "gold"; + has_donated: boolean; + whatsapp_notifications: boolean; + created_at: Date; + updated_at: Date; + last_login: Date | null; + is_active: boolean; + is_verified: boolean; +} + +export interface CreateUserData { + username: string; + email: string; + password_hash: string; + first_name: string; + last_name: string; + phone?: string; + cpf?: string; // CPF agora é opcional +} + +export interface UpdateUserData { + first_name?: string; + last_name?: string; + phone?: string; + whatsapp_notifications?: boolean; +} + +/** + * Cria um novo usuário + */ +export async function createUser( + data: CreateUserData +): Promise<{ userId: number; username: string; email: string }> { + const result = await 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 + `, + [ + data.username, + data.email, + data.password_hash, + data.first_name, + data.last_name, + data.phone || null, + data.cpf || null, + ] + ); + + return { + userId: result.rows[0].user_id, + username: result.rows[0].username, + email: result.rows[0].email, + }; +} + +/** + * Busca usuário por email (inclui password_hash para login) + */ +export async function getUserByEmail(email: string): Promise { + const result = await query( + ` + SELECT * FROM users WHERE email = $1 + `, + [email] + ); + + return result.rows[0] || null; +} + +/** + * Busca usuário por username + */ +export async function getUserByUsername( + username: string +): Promise { + const result = await query( + ` + SELECT * FROM users WHERE username = $1 + `, + [username] + ); + + return result.rows[0] || null; +} + +/** + * Busca usuário por ID (sem password_hash) + */ +export async function getUserById(userId: number): Promise { + const result = await query( + ` + SELECT + user_id, username, email, first_name, last_name, phone, cpf, + account_level, has_donated, whatsapp_notifications, + created_at, updated_at, last_login, is_active, is_verified + FROM users + WHERE user_id = $1 + `, + [userId] + ); + + return result.rows[0] || null; +} + +/** + * Busca usuário por CPF (verifica duplicidade) + */ +export async function getUserByCpf(cpf: string): Promise { + const result = await query( + ` + SELECT user_id, username, email FROM users WHERE cpf = $1 + `, + [cpf] + ); + + return result.rows[0] || null; +} + +/** + * Atualiza dados do usuário + */ +export async function updateUser( + userId: number, + data: UpdateUserData +): Promise { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (data.first_name !== undefined) { + updates.push(`first_name = $${paramIndex++}`); + values.push(data.first_name); + } + + if (data.last_name !== undefined) { + updates.push(`last_name = $${paramIndex++}`); + values.push(data.last_name); + } + + if (data.phone !== undefined) { + updates.push(`phone = $${paramIndex++}`); + values.push(data.phone || null); + } + + if (data.whatsapp_notifications !== undefined) { + updates.push(`whatsapp_notifications = $${paramIndex++}`); + values.push(data.whatsapp_notifications); + } + + if (updates.length === 0) { + // Se não há updates, retorna usuário atual + const user = await getUserById(userId); + if (!user) { + throw new Error("Usuário não encontrado"); + } + return user; + } + + updates.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(userId); + + const result = await query( + ` + UPDATE users + SET ${updates.join(", ")} + WHERE user_id = $${paramIndex} + RETURNING + user_id, username, email, first_name, last_name, phone, cpf, + account_level, has_donated, whatsapp_notifications, + created_at, updated_at, last_login, is_active, is_verified + `, + values + ); + + if (result.rows.length === 0) { + throw new Error("Usuário não encontrado"); + } + + return result.rows[0]; +} + +/** + * Atualiza timestamp de último login + */ +export async function updateLastLogin(userId: number): Promise { + await query( + ` + UPDATE users + SET last_login = CURRENT_TIMESTAMP + WHERE user_id = $1 + `, + [userId] + ); +} + diff --git a/lib/db/seeds/servers-seed.ts b/lib/db/seeds/servers-seed.ts new file mode 100644 index 0000000..2ae9892 --- /dev/null +++ b/lib/db/seeds/servers-seed.ts @@ -0,0 +1,157 @@ +/** + * Seed inicial de servidores do Tibia + * Lista completa de servidores oficiais + */ + +// Carregar variáveis de ambiente +import { config } from "dotenv"; +import { resolve } from "path"; + +// Tentar carregar .env.local primeiro, depois .env +config({ path: resolve(process.cwd(), ".env.local") }); +config({ path: resolve(process.cwd(), ".env") }); + +import { query } from "../index"; + +export interface ServerSeed { + server_name: string; + server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp"; + location: "NA" | "SA" | "EU" | "OC" | "BR"; +} + +// Lista de servidores oficiais do Tibia (servidores únicos) +// Nota: Cada servidor tem um nome único. O tipo pode ser atualizado posteriormente se necessário. +const TIBIA_SERVERS: ServerSeed[] = [ + // Servidores PvP - EU + { server_name: "Antica", server_type: "pvp", location: "EU" }, + { server_name: "Assombra", server_type: "pvp", location: "EU" }, + { server_name: "Astera", server_type: "pvp", location: "EU" }, + { server_name: "Belobra", server_type: "pvp", location: "EU" }, + { server_name: "Bona", server_type: "pvp", location: "EU" }, + { server_name: "Calmera", server_type: "pvp", location: "EU" }, + { server_name: "Celesta", server_type: "pvp", location: "EU" }, + { server_name: "Concorda", server_type: "pvp", location: "EU" }, + { server_name: "Cosera", server_type: "pvp", location: "EU" }, + { server_name: "Damora", server_type: "pvp", location: "EU" }, + { server_name: "Descubra", server_type: "pvp", location: "EU" }, + { server_name: "Dia", server_type: "pvp", location: "EU" }, + { server_name: "Duna", server_type: "pvp", location: "EU" }, + { server_name: "Elera", server_type: "pvp", location: "EU" }, + { server_name: "Epoca", server_type: "pvp", location: "EU" }, + { server_name: "Esmera", server_type: "pvp", location: "EU" }, + { server_name: "Faluna", server_type: "pvp", location: "EU" }, + { server_name: "Famosa", server_type: "pvp", location: "EU" }, + { server_name: "Fera", server_type: "pvp", location: "EU" }, + { server_name: "Ferobra", server_type: "pvp", location: "EU" }, + { server_name: "Firmera", server_type: "pvp", location: "EU" }, + { server_name: "Funera", server_type: "pvp", location: "EU" }, + { server_name: "Furia", server_type: "pvp", location: "EU" }, + { server_name: "Garnera", server_type: "pvp", location: "EU" }, + { server_name: "Gentebra", server_type: "pvp", location: "EU" }, + { server_name: "Gladera", server_type: "pvp", location: "EU" }, + { server_name: "Gravitera", server_type: "pvp", location: "EU" }, + { server_name: "Grimera", server_type: "pvp", location: "EU" }, + { server_name: "Harmonia", server_type: "pvp", location: "EU" }, + { server_name: "Honbra", server_type: "pvp", location: "EU" }, + { server_name: "Impulsa", server_type: "pvp", location: "EU" }, + { server_name: "Inabra", server_type: "pvp", location: "EU" }, + { server_name: "Issobra", server_type: "pvp", location: "EU" }, + { server_name: "Jacabra", server_type: "pvp", location: "EU" }, + { server_name: "Javibra", server_type: "pvp", location: "EU" }, + { server_name: "Jonera", server_type: "pvp", location: "EU" }, + { server_name: "Kalibra", server_type: "pvp", location: "EU" }, + { server_name: "Kardera", server_type: "pvp", location: "EU" }, + { server_name: "Kenora", server_type: "pvp", location: "EU" }, + { server_name: "Libertabra", server_type: "pvp", location: "EU" }, + { server_name: "Lobera", server_type: "pvp", location: "EU" }, + { server_name: "Luminera", server_type: "pvp", location: "EU" }, + { server_name: "Lutabra", server_type: "pvp", location: "EU" }, + { server_name: "Menera", server_type: "pvp", location: "EU" }, + { server_name: "Mitigera", server_type: "pvp", location: "EU" }, + { server_name: "Monza", server_type: "pvp", location: "EU" }, + { server_name: "Nefera", server_type: "pvp", location: "EU" }, + { server_name: "Noctera", server_type: "pvp", location: "EU" }, + { server_name: "Ocera", server_type: "pvp", location: "EU" }, + { server_name: "Ombra", server_type: "pvp", location: "EU" }, + { server_name: "Pacera", server_type: "pvp", location: "EU" }, + { server_name: "Peloria", server_type: "pvp", location: "EU" }, + { server_name: "Premia", server_type: "pvp", location: "EU" }, + { server_name: "Pyra", server_type: "pvp", location: "EU" }, + { server_name: "Quelibra", server_type: "pvp", location: "EU" }, + { server_name: "Quintera", server_type: "pvp", location: "EU" }, + { server_name: "Refugia", server_type: "pvp", location: "EU" }, + { server_name: "Relania", server_type: "pvp", location: "EU" }, + { server_name: "Secura", server_type: "pvp", location: "EU" }, + { server_name: "Serdebra", server_type: "pvp", location: "EU" }, + { server_name: "Solidera", server_type: "pvp", location: "EU" }, + { server_name: "Talonera", server_type: "pvp", location: "EU" }, + { server_name: "Tortura", server_type: "pvp", location: "EU" }, + { server_name: "Unica", server_type: "pvp", location: "EU" }, + { server_name: "Utobra", server_type: "pvp", location: "EU" }, + { server_name: "Venebra", server_type: "pvp", location: "EU" }, + { server_name: "Vita", server_type: "pvp", location: "EU" }, + { server_name: "Vunira", server_type: "pvp", location: "EU" }, + { server_name: "Wadira", server_type: "pvp", location: "EU" }, + { server_name: "Wintera", server_type: "pvp", location: "EU" }, + { server_name: "Xandebra", server_type: "pvp", location: "EU" }, + { server_name: "Yonabra", server_type: "pvp", location: "EU" }, + { server_name: "Zuna", server_type: "pvp", location: "EU" }, + { server_name: "Zunera", server_type: "pvp", location: "EU" }, + + // Servidores No-PvP - EU + { server_name: "Asteria", server_type: "no-pvp", location: "EU" }, + { server_name: "Belluma", server_type: "no-pvp", location: "EU" }, + { server_name: "Calva", server_type: "no-pvp", location: "EU" }, + { server_name: "Castela", server_type: "no-pvp", location: "EU" }, + { server_name: "Collabra", server_type: "no-pvp", location: "EU" }, + { server_name: "Estela", server_type: "no-pvp", location: "EU" }, +]; + +export async function seedServers() { + console.log("🌱 Iniciando seed de servidores...\n"); + + let inserted = 0; + let skipped = 0; + + for (const server of TIBIA_SERVERS) { + try { + // Verificar se já existe + const existing = await query<{ server_id: number }>( + "SELECT server_id FROM servers WHERE server_name = $1", + [server.server_name] + ); + + if (existing.rows.length > 0) { + skipped++; + continue; + } + + // Inserir servidor + await query( + `INSERT INTO servers (server_name, server_type, location, is_active) + VALUES ($1, $2, $3, $4)`, + [server.server_name, server.server_type, server.location, true] + ); + + inserted++; + } catch (error) { + console.error(`❌ Erro ao inserir servidor ${server.server_name}:`, error); + } + } + + console.log(`✅ Seed concluído: ${inserted} servidores inseridos, ${skipped} já existiam\n`); +} + +// Se executado diretamente +if (require.main === module) { + seedServers() + .then(() => { + console.log("🎉 Seed finalizado com sucesso!"); + process.exit(0); + }) + .catch((error) => { + console.error("❌ Erro fatal no seed:", error); + process.exit(1); + }); +} + diff --git a/lib/db/test-connection.ts b/lib/db/test-connection.ts new file mode 100644 index 0000000..8ae768e --- /dev/null +++ b/lib/db/test-connection.ts @@ -0,0 +1,99 @@ +/** + * Script de teste de conexão com PostgreSQL + * Executa um health check e tenta uma query simples + */ + +// Carregar variáveis de ambiente +import { config } from "dotenv"; +import { resolve } from "path"; + +// Tentar carregar .env.local primeiro, depois .env +config({ path: resolve(process.cwd(), ".env.local") }); +config({ path: resolve(process.cwd(), ".env") }); + +import { healthCheck, query, closePool } from "./index"; + +async function testConnection() { + console.log("🔍 Testando conexão com PostgreSQL...\n"); + + // Verificar variável de ambiente + if (!process.env.PG_DB) { + console.error("❌ PG_DB não está definido nas variáveis de ambiente"); + console.log("\n💡 Configure a variável PG_DB:"); + console.log(" export PG_DB=postgresql://user:pass@host:port/db"); + console.log(" ou crie um arquivo .env.local com:"); + console.log(" PG_DB=postgresql://user:pass@host:port/db\n"); + process.exit(1); + } + + console.log("✅ Variável PG_DB encontrada"); + console.log(` Host: ${process.env.PG_DB.split("@")[1]?.split("/")[0] || "não detectado"}\n`); + + try { + // Health check + console.log("📡 Executando health check..."); + const isHealthy = await healthCheck(); + + if (!isHealthy) { + console.error("❌ Health check falhou - banco não está acessível"); + process.exit(1); + } + + console.log("✅ Health check passou\n"); + + // Query de teste + console.log("📊 Executando query de teste..."); + const result = await query("SELECT version(), NOW() as current_time"); + + console.log("✅ Query executada com sucesso"); + console.log(` PostgreSQL Version: ${result.rows[0]?.version?.split(" ")[0] || "N/A"}`); + console.log(` Current Time: ${result.rows[0]?.current_time || "N/A"}\n`); + + // Verificar se as tabelas existem + console.log("🔍 Verificando tabelas..."); + const tablesResult = await query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('users', 'user_characters') + ORDER BY table_name + `); + + const tables = tablesResult.rows.map((row: any) => row.table_name); + + if (tables.length === 0) { + console.log("⚠️ Tabelas não encontradas. Execute as migrations:"); + console.log(" pnpm db:migrate\n"); + } else { + console.log(`✅ Tabelas encontradas: ${tables.join(", ")}\n`); + } + + console.log("🎉 Conexão com PostgreSQL está funcionando corretamente!"); + + // Fechar pool + await closePool(); + process.exit(0); + } catch (error) { + console.error("❌ Erro ao conectar com PostgreSQL:"); + console.error(error instanceof Error ? error.message : String(error)); + + if (error instanceof Error && error.message.includes("timeout")) { + console.log("\n💡 Verifique:"); + console.log(" - O servidor PostgreSQL está acessível?"); + console.log(" - A URL de conexão está correta?"); + console.log(" - O firewall permite conexões?"); + } + + if (error instanceof Error && error.message.includes("authentication")) { + console.log("\n💡 Verifique:"); + console.log(" - Usuário e senha estão corretos?"); + console.log(" - O usuário tem permissão para acessar o banco?"); + } + + await closePool(); + process.exit(1); + } +} + +testConnection(); + diff --git a/lib/hooks/use-servers.ts b/lib/hooks/use-servers.ts new file mode 100644 index 0000000..fe62499 --- /dev/null +++ b/lib/hooks/use-servers.ts @@ -0,0 +1,92 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +export interface Server { + serverId: number; + serverName: string; + serverType: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null; + location: "NA" | "SA" | "EU" | "OC" | "BR" | null; + isActive: boolean; + stats: { + totalListings: number; + totalUsers: number; + totalSubscribers: number; + totalTransactions: number; + totalVolume: number; + averageListingPrice: number; + lastActivityAt: Date | null; + }; +} + +interface UseServersReturn { + servers: Server[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * Hook para buscar e gerenciar servidores disponíveis + */ +export function useServers(): UseServersReturn { + const [servers, setServers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchServers = useCallback(async () => { + // Só buscar no cliente + if (typeof window === "undefined") { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/api/servers", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + // Verificar se a resposta é JSON + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + const text = await response.text(); + console.error("Resposta não é JSON:", text.substring(0, 200)); + setError("Erro: resposta inválida do servidor"); + setIsLoading(false); + return; + } + + const data = await response.json(); + + if (data.success) { + setServers(data.servers || []); + } else { + setError(data.error || "Erro ao buscar servidores"); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Erro desconhecido"; + setError("Erro ao conectar com o servidor"); + console.error("Erro ao buscar servidores:", errorMessage, err); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchServers(); + }, [fetchServers]); + + return { + servers, + isLoading, + error, + refetch: fetchServers, + }; +} + diff --git a/lib/hooks/use-session.ts b/lib/hooks/use-session.ts new file mode 100644 index 0000000..af2767e --- /dev/null +++ b/lib/hooks/use-session.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +export interface SessionUser { + userId: number; + username: string; + email: string; +} + +interface SessionState { + user: SessionUser | null; + isLoading: boolean; + isAuthenticated: boolean; +} + +/** + * Hook para gerenciar sessão do usuário + */ +export function useSession() { + const [session, setSession] = useState({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + + /** + * Verifica a sessão atual + */ + const checkSession = useCallback(async () => { + try { + const response = await fetch("/api/auth/session", { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (data.success && data.authenticated) { + setSession({ + user: data.user, + isLoading: false, + isAuthenticated: true, + }); + } else { + setSession({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + } + } catch (error) { + console.error("Erro ao verificar sessão:", error); + setSession({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + } + }, []); + + /** + * Encerra a sessão + */ + const logout = useCallback(async () => { + try { + await fetch("/api/auth/session", { + method: "DELETE", + credentials: "include", + }); + + setSession({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + + // Recarregar página para limpar estado + window.location.href = "/"; + } catch (error) { + console.error("Erro ao fazer logout:", error); + } + }, []); + + /** + * Atualiza a sessão após login + */ + const refreshSession = useCallback(async () => { + await checkSession(); + }, [checkSession]); + + // Verificar sessão ao montar componente + useEffect(() => { + checkSession(); + }, [checkSession]); + + return { + user: session.user, + isLoading: session.isLoading, + isAuthenticated: session.isAuthenticated, + logout, + refreshSession, + }; +} + diff --git a/lib/rate-limiter.ts b/lib/rate-limiter.ts index edf1163..27386fb 100644 --- a/lib/rate-limiter.ts +++ b/lib/rate-limiter.ts @@ -35,6 +35,22 @@ const RATE_LIMITS: Record = { maxRequests: 100, windowMs: 60 * 1000, // 1 minuto }, + "/api/auth/register": { + maxRequests: 5, + windowMs: 10 * 60 * 1000, // 10 minutos + }, + "/api/auth/login": { + maxRequests: 10, + windowMs: 60 * 1000, // 1 minuto + }, + "/api/auth/refresh": { + maxRequests: 20, + windowMs: 60 * 1000, // 1 minuto + }, + "/api/auth/me": { + maxRequests: 30, + windowMs: 60 * 1000, // 1 minuto + }, }; // Limpar cache expirado a cada 5 minutos diff --git a/lib/redis/index.ts b/lib/redis/index.ts new file mode 100644 index 0000000..00a3f67 --- /dev/null +++ b/lib/redis/index.ts @@ -0,0 +1,96 @@ +/** + * Módulo de conexão com Redis + * Gerencia conexão e fornece helpers para cache e sessões + */ + +import { createClient, RedisClientType } from "redis"; + +let client: RedisClientType | null = null; + +/** + * Valida variáveis de ambiente necessárias + */ +function validateEnvVars() { + if (!process.env.REDIS_DB) { + throw new Error( + "REDIS_DB não está definido. Configure a variável de ambiente REDIS_DB com a connection string do Redis." + ); + } +} + +/** + * Obtém ou cria o cliente Redis + */ +export function getRedisClient(): RedisClientType { + if (!client) { + validateEnvVars(); + + const connectionString = process.env.REDIS_DB; + + client = createClient({ + url: connectionString, + }); + + // Event listeners para debug + client.on("error", (err) => { + console.error("❌ Erro no cliente Redis:", err); + }); + + client.on("connect", () => { + console.log("✅ Conectado ao Redis"); + }); + + client.on("ready", () => { + console.log("✅ Redis está pronto para uso"); + }); + + // Conectar + client.connect().catch((err) => { + console.error("❌ Erro ao conectar com Redis:", err); + }); + } + + return client; +} + +/** + * Verifica se Redis está conectado + */ +export async function isRedisConnected(): Promise { + try { + const redisClient = getRedisClient(); + if (!redisClient.isOpen) { + await redisClient.connect(); + } + await redisClient.ping(); + return true; + } catch (error) { + console.error("❌ Redis não está conectado:", error); + return false; + } +} + +/** + * Fecha conexão com Redis + */ +export async function closeRedis(): Promise { + if (client && client.isOpen) { + await client.quit(); + client = null; + console.log("🔌 Conexão Redis fechada"); + } +} + +// Cleanup ao encerrar o processo +if (typeof process !== "undefined") { + process.on("SIGINT", async () => { + await closeRedis(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + await closeRedis(); + process.exit(0); + }); +} + diff --git a/lib/redis/session.ts b/lib/redis/session.ts new file mode 100644 index 0000000..b7e7769 --- /dev/null +++ b/lib/redis/session.ts @@ -0,0 +1,143 @@ +/** + * Sistema de sessão usando Redis + * Armazena informações de sessão do usuário + */ + +import { getRedisClient } from "./index"; + +const SESSION_PREFIX = "session:"; +const USER_SESSION_PREFIX = "user_session:"; +const SESSION_TTL = 60 * 60 * 24 * 7; // 7 dias (mesmo que refresh token) + +export interface SessionData { + userId: number; + username: string; + email: string; + accessToken: string; + createdAt: number; +} + +/** + * Cria ou atualiza uma sessão + */ +export async function setSession( + sessionId: string, + data: SessionData +): Promise { + try { + const client = getRedisClient(); + if (!client.isOpen) { + await client.connect(); + } + + const key = `${SESSION_PREFIX}${sessionId}`; + await client.setEx(key, SESSION_TTL, JSON.stringify(data)); + + // Também armazenar referência por userId para invalidar todas as sessões + const userKey = `${USER_SESSION_PREFIX}${data.userId}`; + await client.sAdd(userKey, sessionId); + await client.expire(userKey, SESSION_TTL); + } catch (error) { + console.error("❌ Erro ao criar sessão:", error); + throw error; + } +} + +/** + * Obtém dados de uma sessão + */ +export async function getSession( + sessionId: string +): Promise { + try { + const client = getRedisClient(); + if (!client.isOpen) { + await client.connect(); + } + + const key = `${SESSION_PREFIX}${sessionId}`; + const data = await client.get(key); + + if (!data) { + return null; + } + + return JSON.parse(data) as SessionData; + } catch (error) { + console.error("❌ Erro ao obter sessão:", error); + return null; + } +} + +/** + * Remove uma sessão + */ +export async function deleteSession(sessionId: string): Promise { + try { + const client = getRedisClient(); + if (!client.isOpen) { + await client.connect(); + } + + const key = `${SESSION_PREFIX}${sessionId}`; + const data = await client.get(key); + + if (data) { + const sessionData = JSON.parse(data) as SessionData; + const userKey = `${USER_SESSION_PREFIX}${sessionData.userId}`; + await client.sRem(userKey, sessionId); + } + + await client.del(key); + } catch (error) { + console.error("❌ Erro ao remover sessão:", error); + throw error; + } +} + +/** + * Remove todas as sessões de um usuário + */ +export async function deleteAllUserSessions(userId: number): Promise { + try { + const client = getRedisClient(); + if (!client.isOpen) { + await client.connect(); + } + + const userKey = `${USER_SESSION_PREFIX}${userId}`; + const sessionIds = await client.sMembers(userKey); + + if (sessionIds.length > 0) { + const keys = sessionIds.map((id) => `${SESSION_PREFIX}${id}`); + await client.del(keys); + await client.del(userKey); + } + } catch (error) { + console.error("❌ Erro ao remover todas as sessões do usuário:", error); + throw error; + } +} + +/** + * Atualiza TTL de uma sessão + */ +export async function refreshSession(sessionId: string): Promise { + try { + const client = getRedisClient(); + if (!client.isOpen) { + await client.connect(); + } + + const key = `${SESSION_PREFIX}${sessionId}`; + const exists = await client.exists(key); + + if (exists) { + await client.expire(key, SESSION_TTL); + } + } catch (error) { + console.error("❌ Erro ao atualizar TTL da sessão:", error); + throw error; + } +} + diff --git a/lib/utils/countries.ts b/lib/utils/countries.ts new file mode 100644 index 0000000..2762d75 --- /dev/null +++ b/lib/utils/countries.ts @@ -0,0 +1,74 @@ +/** + * Lista de países com códigos de área para seleção de telefone + */ + +export interface Country { + code: string; // Código ISO 3166-1 alpha-2 + name: string; // Nome do país + dialCode: string; // Código de discagem internacional + flag: string; // Emoji da bandeira +} + +/** + * Lista de países mais comuns com seus códigos de discagem + * Ordenado por popularidade/uso + */ +export const COUNTRIES: Country[] = [ + { code: "BR", name: "Brasil", dialCode: "+55", flag: "🇧🇷" }, + { code: "US", name: "Estados Unidos", dialCode: "+1", flag: "🇺🇸" }, + { code: "CA", name: "Canadá", dialCode: "+1", flag: "🇨🇦" }, + { code: "MX", name: "México", dialCode: "+52", flag: "🇲🇽" }, + { code: "AR", name: "Argentina", dialCode: "+54", flag: "🇦🇷" }, + { code: "CL", name: "Chile", dialCode: "+56", flag: "🇨🇱" }, + { code: "CO", name: "Colômbia", dialCode: "+57", flag: "🇨🇴" }, + { code: "PE", name: "Peru", dialCode: "+51", flag: "🇵🇪" }, + { code: "PT", name: "Portugal", dialCode: "+351", flag: "🇵🇹" }, + { code: "ES", name: "Espanha", dialCode: "+34", flag: "🇪🇸" }, + { code: "GB", name: "Reino Unido", dialCode: "+44", flag: "🇬🇧" }, + { code: "FR", name: "França", dialCode: "+33", flag: "🇫🇷" }, + { code: "DE", name: "Alemanha", dialCode: "+49", flag: "🇩🇪" }, + { code: "IT", name: "Itália", dialCode: "+39", flag: "🇮🇹" }, + { code: "NL", name: "Holanda", dialCode: "+31", flag: "🇳🇱" }, + { code: "BE", name: "Bélgica", dialCode: "+32", flag: "🇧🇪" }, + { code: "CH", name: "Suíça", dialCode: "+41", flag: "🇨🇭" }, + { code: "AT", name: "Áustria", dialCode: "+43", flag: "🇦🇹" }, + { code: "PL", name: "Polônia", dialCode: "+48", flag: "🇵🇱" }, + { code: "RU", name: "Rússia", dialCode: "+7", flag: "🇷🇺" }, + { code: "CN", name: "China", dialCode: "+86", flag: "🇨🇳" }, + { code: "JP", name: "Japão", dialCode: "+81", flag: "🇯🇵" }, + { code: "KR", name: "Coreia do Sul", dialCode: "+82", flag: "🇰🇷" }, + { code: "IN", name: "Índia", dialCode: "+91", flag: "🇮🇳" }, + { code: "AU", name: "Austrália", dialCode: "+61", flag: "🇦🇺" }, + { code: "NZ", name: "Nova Zelândia", dialCode: "+64", flag: "🇳🇿" }, + { code: "ZA", name: "África do Sul", dialCode: "+27", flag: "🇿🇦" }, + { code: "EG", name: "Egito", dialCode: "+20", flag: "🇪🇬" }, + { code: "TR", name: "Turquia", dialCode: "+90", flag: "🇹🇷" }, + { code: "SA", name: "Arábia Saudita", dialCode: "+966", flag: "🇸🇦" }, + { code: "AE", name: "Emirados Árabes", dialCode: "+971", flag: "🇦🇪" }, + { code: "IL", name: "Israel", dialCode: "+972", flag: "🇮🇱" }, + { code: "UY", name: "Uruguai", dialCode: "+598", flag: "🇺🇾" }, + { code: "PY", name: "Paraguai", dialCode: "+595", flag: "🇵🇾" }, + { code: "BO", name: "Bolívia", dialCode: "+591", flag: "🇧🇴" }, + { code: "VE", name: "Venezuela", dialCode: "+58", flag: "🇻🇪" }, + { code: "EC", name: "Equador", dialCode: "+593", flag: "🇪🇨" }, +]; + +/** + * País padrão (Brasil) + */ +export const DEFAULT_COUNTRY: Country = COUNTRIES[0]; + +/** + * Busca país por código + */ +export function getCountryByCode(code: string): Country | undefined { + return COUNTRIES.find((c) => c.code === code); +} + +/** + * Busca país por código de discagem + */ +export function getCountryByDialCode(dialCode: string): Country | undefined { + return COUNTRIES.find((c) => c.dialCode === dialCode); +} + diff --git a/lib/utils/formatters.ts b/lib/utils/formatters.ts new file mode 100644 index 0000000..ab0f843 --- /dev/null +++ b/lib/utils/formatters.ts @@ -0,0 +1,448 @@ +/** + * Funções utilitárias para formatação de dados + */ + +/** + * Formata número de telefone brasileiro + * Exemplo: 11988887777 -> (11) 98888-7777 + */ +export function formatPhone(value: string): string { + const cleaned = value.replace(/\D/g, ""); + + if (cleaned.length <= 2) { + return cleaned; + } + + if (cleaned.length <= 6) { + return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2)}`; + } + + if (cleaned.length <= 10) { + return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`; + } + + // Telefone com 11 dígitos (celular) + return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7, 11)}`; +} + +/** + * Formata número de telefone específico por país + * @param phoneNumber - Número do telefone (apenas dígitos, sem código de país) + * @param countryCode - Código ISO do país (ex: "BR", "US", "GB") + */ +function formatPhoneByCountry(phoneNumber: string, countryCode: string): string { + const len = phoneNumber.length; + + switch (countryCode) { + // Brasil: (XX) XXXXX-XXXX ou (XX) XXXX-XXXX + case "BR": + if (len <= 2) return phoneNumber; + if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`; + if (len <= 10) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6)}`; + return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 7)}-${phoneNumber.slice(7, 11)}`; + + // EUA/Canadá: (XXX) XXX-XXXX + case "US": + case "CA": + if (len <= 3) return phoneNumber; + if (len <= 6) return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`; + return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`; + + // México: (XX) XXXX XXXX + case "MX": + if (len <= 2) return phoneNumber; + if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`; + return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)} ${phoneNumber.slice(6, 10)}`; + + // Argentina: (XX) XXXX-XXXX + case "AR": + if (len <= 2) return phoneNumber; + if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`; + return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6, 10)}`; + + // Chile: X XXXX XXXX + case "CL": + if (len <= 1) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 1)} ${phoneNumber.slice(1)}`; + return `${phoneNumber.slice(0, 1)} ${phoneNumber.slice(1, 5)} ${phoneNumber.slice(5, 9)}`; + + // Colômbia: XXX XXX XXXX + case "CO": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 10)}`; + + // Peru: XXX XXX XXX + case "PE": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`; + + // Portugal: XXX XXX XXX + case "PT": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`; + + // Espanha: XXX XXX XXX + case "ES": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`; + + // Reino Unido: XXXX XXX XXXX + case "GB": + if (len <= 4) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 11)}`; + + // França: XX XX XX XX XX + case "FR": { + if (len <= 2) return phoneNumber; + const groups: string[] = []; + for (let i = 0; i < len; i += 2) { + if (i + 2 <= len) { + groups.push(phoneNumber.slice(i, i + 2)); + } else { + groups.push(phoneNumber.slice(i)); + } + } + return groups.join(" "); + } + + // Alemanha: XXXX XXXXXXX + case "DE": + if (len <= 4) return phoneNumber; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + + // Itália: XXX XXX XXXX + case "IT": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 10)}`; + + // Holanda: XX XXXXXXXX + case "NL": + if (len <= 2) return phoneNumber; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + + // Bélgica: XXXX XX XX XX + case "BE": + if (len <= 4) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + if (len <= 8) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 6)} ${phoneNumber.slice(6)}`; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 6)} ${phoneNumber.slice(6, 8)} ${phoneNumber.slice(8)}`; + + // Suíça: XX XXX XX XX + case "CH": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + if (len <= 7) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 7)} ${phoneNumber.slice(7)}`; + + // Áustria: XXXX XXXX + case "AT": + if (len <= 4) return phoneNumber; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`; + + // Polônia: XXX XXX XXX + case "PL": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`; + + // Rússia: XXX XXX-XX-XX + case "RU": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + if (len <= 8) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 8)}-${phoneNumber.slice(8)}`; + + // China: XXX XXXX XXXX + case "CN": + if (len <= 3) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 7)} ${phoneNumber.slice(7, 11)}`; + + // Japão: XX-XXXX-XXXX + case "JP": + if (len <= 2) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6, 10)}`; + + // Coreia do Sul: XXX-XXXX-XXXX + case "KR": + if (len <= 3) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 7)}-${phoneNumber.slice(7, 11)}`; + + // Índia: XXXX XXX XXX + case "IN": + if (len <= 4) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`; + + // Austrália: XXXX XXX XXX + case "AU": + if (len <= 4) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`; + + // Nova Zelândia: XXXX XXX XXX + case "NZ": + if (len <= 4) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`; + + // África do Sul: XX XXX XXXX + case "ZA": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`; + + // Turquia: XXX XXX XX XX + case "TR": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + if (len <= 8) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 8)} ${phoneNumber.slice(8)}`; + + // Arábia Saudita: XX XXX XXXX + case "SA": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`; + + // Emirados Árabes: XX XXX XXXX + case "AE": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`; + + // Israel: XX-XXX-XXXX + case "IL": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2, 5)}-${phoneNumber.slice(5, 9)}`; + + // Uruguai: XXXX XXXX + case "UY": + if (len <= 4) return phoneNumber; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`; + + // Paraguai: XXX XXX XXX + case "PY": + if (len <= 3) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`; + + // Bolívia: XXXX XXXX + case "BO": + if (len <= 4) return phoneNumber; + return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`; + + // Venezuela: XXXX-XXXXXXX + case "VE": + if (len <= 4) return phoneNumber; + return `${phoneNumber.slice(0, 4)}-${phoneNumber.slice(4)}`; + + // Equador: XX XXX XXXX + case "EC": + if (len <= 2) return phoneNumber; + if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`; + + // Egito: XX XXXX XXXX + case "EG": + if (len <= 2) return phoneNumber; + if (len <= 6) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`; + return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 6)} ${phoneNumber.slice(6, 10)}`; + + // Formatação genérica padrão (espaços a cada 3-4 dígitos) + default: { + if (len <= 4) return phoneNumber; + if (len <= 7) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`; + if (len <= 10) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6)}`; + + // Para números maiores, agrupa em grupos de 3-4 dígitos + const groups: string[] = []; + let remaining = phoneNumber; + + while (remaining.length > 4) { + groups.push(remaining.slice(0, 3)); + remaining = remaining.slice(3); + } + + if (remaining.length > 0) { + groups.push(remaining); + } + + return groups.join(" "); + } + } +} + +/** + * Gera um placeholder de telefone baseado no formato do país + * @param countryCode - Código ISO do país (ex: "BR", "US", "GB") + * @returns Placeholder formatado (ex: "(11) 98888-7777") + */ +export function getPhonePlaceholder(countryCode: string): string { + // Números exemplo para cada país (apenas para mostrar o formato) + const examples: Record = { + BR: "(11) 98888-7777", + US: "(415) 555-1234", + CA: "(416) 555-1234", + MX: "(55) 1234 5678", + AR: "(11) 1234-5678", + CL: "9 1234 5678", + CO: "300 123 4567", + PE: "987 654 321", + PT: "912 345 678", + ES: "612 345 678", + GB: "7123 456 789", + FR: "61 23 45 67 89", + DE: "3012 3456789", + IT: "321 234 5678", + NL: "61 23456789", + BE: "1234 56 78 90", + CH: "21 321 45 67", + AT: "1234 5678", + PL: "123 456 789", + RU: "495 123-45-67", + CN: "138 1234 5678", + JP: "90-1234-5678", + KR: "010-1234-5678", + IN: "9876 543 210", + AU: "0412 345 678", + NZ: "021 234 5678", + ZA: "82 123 4567", + TR: "532 123 45 67", + SA: "50 123 4567", + AE: "50 123 4567", + IL: "50-123-4567", + UY: "1234 5678", + PY: "981 234 567", + BO: "7123 4567", + VE: "2126-1234567", + EC: "99 123 4567", + EG: "10 1234 5678", + }; + + return examples[countryCode] || "123 456 7890"; +} + +/** + * Formata número de telefone internacional com código de país + * @param value - Número do telefone (com ou sem código de país) + * @param dialCode - Código de discagem internacional (ex: "+55") + * @param countryCode - Código ISO do país (ex: "BR") + */ +export function formatInternationalPhone( + value: string, + dialCode: string, + countryCode?: string +): string { + const cleaned = value.replace(/\D/g, ""); + + // Remove o código de país se já estiver presente + let phoneNumber = cleaned; + const dialCodeDigits = dialCode.replace(/\D/g, ""); + + if (phoneNumber.startsWith(dialCodeDigits)) { + phoneNumber = phoneNumber.slice(dialCodeDigits.length); + } + + // Se não temos código de país, usar formatação genérica + if (!countryCode) { + return formatPhoneByCountry(phoneNumber, "DEFAULT"); + } + + // Aplicar formatação específica do país + return formatPhoneByCountry(phoneNumber, countryCode); +} + +/** + * Remove formatação do telefone + * Exemplo: (11) 98888-7777 -> 11988887777 + */ +export function unformatPhone(value: string): string { + return value.replace(/\D/g, ""); +} + +/** + * Combina código de país com número de telefone + * @param dialCode - Código de discagem (ex: "+55") + * @param phoneNumber - Número do telefone (sem código) + */ +export function combinePhoneWithDialCode(dialCode: string, phoneNumber: string): string { + const cleanedPhone = unformatPhone(phoneNumber); + const cleanedDialCode = dialCode.replace(/\D/g, ""); + + // Remove código de país se já estiver presente + if (cleanedPhone.startsWith(cleanedDialCode)) { + return `${dialCode}${cleanedPhone.slice(cleanedDialCode.length)}`; + } + + return `${dialCode}${cleanedPhone}`; +} + +/** + * Calcula a força da senha + * Retorna: 'weak' | 'medium' | 'strong' + */ +export function checkPasswordStrength(password: string): "weak" | "medium" | "strong" { + if (password.length < 8) { + return "weak"; + } + + let strength = 0; + + // Verificar requisitos básicos + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + if (/[a-z]/.test(password)) strength++; + if (/[A-Z]/.test(password)) strength++; + if (/[0-9]/.test(password)) strength++; + if (/[^a-zA-Z0-9]/.test(password)) strength++; // Caracteres especiais + + if (strength <= 3) { + return "weak"; + } else if (strength <= 5) { + return "medium"; + } else { + return "strong"; + } +} + +/** + * Valida se a senha atende aos requisitos mínimos + */ +export function validatePasswordRequirements(password: string): { + isValid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (password.length < 8) { + errors.push("Senha deve ter no mínimo 8 caracteres"); + } + + if (!/[A-Z]/.test(password)) { + errors.push("Senha deve conter pelo menos 1 letra maiúscula"); + } + + if (!/[a-z]/.test(password)) { + errors.push("Senha deve conter pelo menos 1 letra minúscula"); + } + + if (!/[0-9]/.test(password)) { + errors.push("Senha deve conter pelo menos 1 número"); + } + + return { + isValid: errors.length === 0, + errors, + }; +} + diff --git a/lib/validation-schemas.ts b/lib/validation-schemas.ts index 025c689..a2c7a1e 100644 --- a/lib/validation-schemas.ts +++ b/lib/validation-schemas.ts @@ -18,17 +18,30 @@ export const emailSchema = z }) .transform((email) => email.trim().toLowerCase()); -// Schema para telefone brasileiro +// Schema para telefone (aceita brasileiro e internacional) export const phoneSchema = z .string() .min(1, "Telefone é obrigatório") .refine((phone) => { - // Remove todos os caracteres não numéricos + // Remove todos os caracteres não numéricos (incluindo +) + // Aceita formato internacional com código de país (ex: +55119988887777) const cleaned = phone.replace(/[^\d]/g, ""); - // Deve ter 10 ou 11 dígitos (telefone fixo ou celular com DDD) - return cleaned.length >= 10 && cleaned.length <= 11; - }, "Telefone deve ter 10 ou 11 dígitos (incluindo DDD)") - .transform((phone) => phone.replace(/[^\d]/g, "")); + // Aceita telefones de 8 a 15 dígitos (padrão internacional E.164) + // O código de país pode ter 1-3 dígitos, então o número total pode ser maior + // Mas para validação, aceitamos de 10 a 16 dígitos (código + número) + return cleaned.length >= 10 && cleaned.length <= 16; + }, "Telefone deve ter entre 10 e 16 dígitos (incluindo código de país)") + .transform((phone) => { + // Remove todos os caracteres não numéricos, mantendo apenas dígitos + // O formato final será: código de país + número (ex: 55119988887777) + return phone.replace(/[^\d]/g, ""); + }); + +// Schema para telefone com código de país (para registro) +export const phoneWithCountrySchema = z.object({ + dialCode: z.string().min(1, "Código de país é obrigatório"), + phoneNumber: phoneSchema, +}); // Schema para CPF/CNPJ export const documentSchema = z @@ -136,7 +149,219 @@ export const verifyBillingSchema = z.object({ billingId: z.string().min(1, "ID da cobrança é obrigatório"), }); +// ============================================ +// SCHEMAS DE AUTENTICAÇÃO E USUÁRIOS +// ============================================ + +// Schema para username (alfanumérico + underscore) +export const usernameSchema = z + .string() + .min(3, "Username deve ter no mínimo 3 caracteres") + .max(50, "Username deve ter no máximo 50 caracteres") + .regex( + /^[a-zA-Z0-9_]+$/, + "Username deve conter apenas letras, números e underscore" + ) + .transform((username) => username.toLowerCase().trim()); + +// Schema para senha (complexidade mínima) +export const passwordSchema = z + .string() + .min(8, "Senha deve ter no mínimo 8 caracteres") + .refine( + (password) => { + // Pelo menos 1 maiúscula + if (!/[A-Z]/.test(password)) return false; + // Pelo menos 1 minúscula + if (!/[a-z]/.test(password)) return false; + // Pelo menos 1 número + if (!/[0-9]/.test(password)) return false; + return true; + }, + { + message: + "Senha deve conter pelo menos 1 letra maiúscula, 1 letra minúscula e 1 número", + } + ); + +// Schema para servidor/personagem no registro +export const registerServerCharacterSchema = z.object({ + serverName: z + .string() + .min(1, "Nome do servidor é obrigatório") + .max(50, "Nome do servidor deve ter no máximo 50 caracteres") + .transform((name) => name.trim()), + characterName: z + .string() + .min(3, "Nome do personagem deve ter no mínimo 3 caracteres") + .max(100, "Nome do personagem deve ter no máximo 100 caracteres") + .transform((name) => name.trim()), + isMain: z.boolean().optional().default(false), +}); + +// Schema para registro de usuário +export const registerSchema = z.object({ + username: usernameSchema, + email: emailSchema, + password: passwordSchema, + firstName: nameSchema, + lastName: nameSchema, + phone: phoneSchema.optional(), + servers: z + .array(registerServerCharacterSchema) + .min(1, "Selecione pelo menos um servidor"), +}); + +// Schema para login (aceita email ou username) +export const loginSchema = z.object({ + emailOrUsername: z + .string() + .min(1, "Email ou username é obrigatório") + .transform((val) => val.trim().toLowerCase()), + password: z.string().min(1, "Senha é obrigatória"), +}); + +// Schema para atualização de perfil +export const updateUserSchema = z.object({ + firstName: nameSchema.optional(), + lastName: nameSchema.optional(), + phone: phoneSchema.optional(), + whatsappNotifications: z.boolean().optional(), +}); + +// Schema para personagem +export const characterSchema = z.object({ + characterName: z + .string() + .min(3, "Nome do personagem deve ter no mínimo 3 caracteres") + .max(100, "Nome do personagem deve ter no máximo 100 caracteres") + .transform((name) => name.trim()), + serverName: z + .string() + .min(1, "Nome do servidor é obrigatório") + .max(50, "Nome do servidor deve ter no máximo 50 caracteres") + .transform((name) => name.trim()), + isMain: z.boolean().optional().default(false), +}); + +// ============================================ +// SCHEMAS DE MARKETPLACE (LISTINGS E BIDS) +// ============================================ + +// Schema para criar anúncio (listing) +export const createListingSchema = z.object({ + item_id: z.number().int().positive("ID do item deve ser positivo"), + item_name: z + .string() + .min(1, "Nome do item é obrigatório") + .max(255, "Nome do item deve ter no máximo 255 caracteres") + .transform((name) => name.trim()), + quantity: z + .number() + .int() + .positive("Quantidade deve ser maior que zero") + .default(1), + server_name: z + .string() + .min(1, "Nome do servidor é obrigatório") + .max(50, "Nome do servidor deve ter no máximo 50 caracteres") + .transform((name) => name.trim()), + price_pix: z + .number() + .positive("Preço PIX deve ser positivo") + .optional() + .nullable(), + price_tibia_coins: z + .number() + .int() + .min(0, "Preço em Tibia Coins deve ser não negativo") + .optional() + .nullable(), + price_gold_coins: z + .number() + .int() + .min(0, "Preço em Gold Coins deve ser não negativo") + .optional() + .nullable(), + is_auction: z.boolean().optional().default(false), + auction_ends_at: z + .string() + .datetime("Data de término do leilão inválida") + .optional() + .nullable() + .transform((val) => (val ? new Date(val) : null)), + description: z + .string() + .max(5000, "Descrição deve ter no máximo 5000 caracteres") + .optional() + .nullable() + .transform((val) => val?.trim() || null), +}).refine( + (data) => { + // Pelo menos um preço deve ser fornecido + return ( + data.price_pix !== null || + data.price_tibia_coins !== null || + data.price_gold_coins !== null + ); + }, + { + message: "Pelo menos um método de pagamento (PIX, Tibia Coins ou Gold Coins) deve ser especificado", + path: ["price_pix"], + } +).refine( + (data) => { + // Se for leilão, deve ter data de término + if (data.is_auction && !data.auction_ends_at) { + return false; + } + return true; + }, + { + message: "Data de término do leilão é obrigatória quando is_auction é true", + path: ["auction_ends_at"], + } +); + +// Schema para atualizar anúncio +export const updateListingSchema = z.object({ + quantity: z.number().int().positive().optional(), + price_pix: z.number().positive().optional().nullable(), + price_tibia_coins: z.number().int().min(0).optional().nullable(), + price_gold_coins: z.number().int().min(0).optional().nullable(), + description: z.string().max(5000).optional().nullable(), + listing_status: z + .enum(["active", "sold", "cancelled", "in_escrow", "expired"]) + .optional(), +}).refine( + (data) => { + // Se atualizando preços, pelo menos um deve ser fornecido ou manter existentes + const hasPrice = + data.price_pix !== undefined || + data.price_tibia_coins !== undefined || + data.price_gold_coins !== undefined; + return true; // Permite atualizar sem especificar preços (mantém existentes) + } +); + +// Schema para criar lance (bid) +export const bidSchema = z.object({ + listing_id: z.number().int().positive("ID do anúncio deve ser positivo"), + bid_amount: z.number().positive("Valor do lance deve ser positivo"), + bid_type: z + .enum(["pix", "tibia_coins", "gold_coins"]) + .optional() + .nullable(), +}); + // Type exports para uso no código export type CreateDonationInput = z.infer; export type VerifyStatusInput = z.infer; export type VerifyBillingInput = z.infer; +export type RegisterInput = z.infer; +export type LoginInput = z.infer; +export type UpdateUserInput = z.infer; +export type CharacterInput = z.infer; +export type CreateListingInput = z.infer; +export type UpdateListingInput = z.infer; +export type BidInput = z.infer; diff --git a/messages/en.json b/messages/en.json index 8ddb9bd..d357da6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -329,5 +329,77 @@ "share": "Share with friends", "back": "Back to home" } + }, + "auth": { + "login": { + "title": "Sign In", + "description": "Sign in to access your account", + "emailOrUsername": "Email or Username", + "emailOrUsernamePlaceholder": "your@email.com or username", + "password": "Password", + "passwordPlaceholder": "••••••••", + "loginButton": "Sign In", + "loggingIn": "Signing in...", + "invalidCredentials": "Invalid credentials" + }, + "register": { + "title": "Create Account", + "description": "Register to start trading", + "username": "Username", + "usernamePlaceholder": "your_username", + "usernameHint": "3-50 characters, letters, numbers and _", + "email": "Email", + "emailPlaceholder": "your@email.com", + "password": "Password", + "passwordPlaceholder": "••••••••", + "passwordHint": "Min. 8 characters, 1 uppercase, 1 lowercase, 1 number", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "••••••••", + "firstName": "First Name", + "firstNamePlaceholder": "Your first name", + "lastName": "Last Name", + "lastNamePlaceholder": "Your last name", + "phone": "Phone (optional)", + "phonePlaceholder": "(11) 98888-7777", + "phoneHint": "Select country and enter phone number", + "searchCountry": "Search country...", + "servers": "Servers", + "serversDescription": "Select the servers where you play", + "selectServer": "Select Server", + "characterName": "Character Name", + "characterNamePlaceholder": "Your character name", + "characterNameHint": "Min. 3 characters", + "addServer": "Add Server", + "removeServer": "Remove", + "mainCharacter": "Main Character", + "atLeastOneServer": "Select at least one server", + "characterRequired": "Character name is required", + "submit": "Create Account", + "submitting": "Creating account...", + "success": "Account created successfully!", + "errors": { + "emailExists": "This email is already registered", + "usernameExists": "This username is already in use", + "characterExists": "This character is already registered on this server", + "serverNotFound": "Server not found or inactive", + "passwordMismatch": "Passwords do not match", + "weakPassword": "Password too weak", + "invalidEmail": "Invalid email", + "invalidPhone": "Invalid phone", + "noServers": "Select at least one server", + "characterNameRequired": "Character name is required for all selected servers" + }, + "passwordStrength": { + "weak": "Weak", + "medium": "Medium", + "strong": "Strong" + }, + "passwordRequirements": { + "minLength": "Minimum 8 characters", + "uppercase": "1 uppercase letter", + "lowercase": "1 lowercase letter", + "number": "1 number" + } + } } } diff --git a/messages/pt.json b/messages/pt.json index 9eaafb4..8c64f90 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -329,5 +329,77 @@ "share": "Compartilhe com amigos", "back": "Voltar ao início" } + }, + "auth": { + "login": { + "title": "Entrar", + "description": "Faça login para acessar sua conta", + "emailOrUsername": "Email ou Username", + "emailOrUsernamePlaceholder": "seu@email.com ou username", + "password": "Senha", + "passwordPlaceholder": "••••••••", + "loginButton": "Entrar", + "loggingIn": "Entrando...", + "invalidCredentials": "Credenciais inválidas" + }, + "register": { + "title": "Criar Conta", + "description": "Registre-se para começar a negociar", + "username": "Username", + "usernamePlaceholder": "seu_username", + "usernameHint": "3-50 caracteres, letras, números e _", + "email": "Email", + "emailPlaceholder": "seu@email.com", + "password": "Senha", + "passwordPlaceholder": "••••••••", + "passwordHint": "Mín. 8 caracteres, 1 maiúscula, 1 minúscula, 1 número", + "confirmPassword": "Confirmar Senha", + "confirmPasswordPlaceholder": "••••••••", + "firstName": "Nome", + "firstNamePlaceholder": "Seu nome", + "lastName": "Sobrenome", + "lastNamePlaceholder": "Seu sobrenome", + "phone": "Telefone (opcional)", + "phonePlaceholder": "(11) 98888-7777", + "phoneHint": "Selecione o país e informe o telefone", + "searchCountry": "Buscar país...", + "servers": "Servidores", + "serversDescription": "Selecione os servidores onde você joga", + "selectServer": "Selecionar Servidor", + "characterName": "Nome do Personagem", + "characterNamePlaceholder": "Nome do seu personagem", + "characterNameHint": "Mín. 3 caracteres", + "addServer": "Adicionar Servidor", + "removeServer": "Remover", + "mainCharacter": "Personagem Principal", + "atLeastOneServer": "Selecione pelo menos um servidor", + "characterRequired": "Nome do personagem é obrigatório", + "submit": "Criar Conta", + "submitting": "Criando conta...", + "success": "Conta criada com sucesso!", + "errors": { + "emailExists": "Este email já está cadastrado", + "usernameExists": "Este username já está em uso", + "characterExists": "Este personagem já está cadastrado neste servidor", + "serverNotFound": "Servidor não encontrado ou inativo", + "passwordMismatch": "As senhas não coincidem", + "weakPassword": "Senha muito fraca", + "invalidEmail": "Email inválido", + "invalidPhone": "Telefone inválido", + "noServers": "Selecione pelo menos um servidor", + "characterNameRequired": "Nome do personagem é obrigatório para todos os servidores selecionados" + }, + "passwordStrength": { + "weak": "Fraca", + "medium": "Média", + "strong": "Forte" + }, + "passwordRequirements": { + "minLength": "Mínimo 8 caracteres", + "uppercase": "1 letra maiúscula", + "lowercase": "1 letra minúscula", + "number": "1 número" + } + } } } diff --git a/package.json b/package.json index a70955b..c13665c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build --turbopack", "start": "next start", "lint": "biome check", - "format": "biome format --write" + "format": "biome format --write", + "db:migrate": "tsx lib/db/migrations/run-migrations.ts", + "db:seed": "tsx lib/db/seeds/servers-seed.ts", + "db:test": "tsx lib/db/test-connection.ts" }, "keywords": [ "tibia marketplace", @@ -26,25 +29,33 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@types/bcrypt": "^6.0.0", "@types/canvas-confetti": "^1.9.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/pg": "^8.15.6", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.4.1", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.5", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.540.0", "motion": "^12.23.24", "next": "15.5.0", "next-intl": "^4.4.0", "next-themes": "^0.4.6", + "pg": "^8.16.3", "react": "19.1.0", "react-dom": "19.1.0", "react-qr-code": "^2.0.18", "recharts": "^3.3.0", + "redis": "^5.9.0", "sanitize-html": "^2.17.0", "tailwind-merge": "^3.3.1", "validator": "^13.15.20", @@ -58,7 +69,9 @@ "@types/react-dom": "^19", "@types/sanitize-html": "^2.16.0", "@types/validator": "^13.15.3", + "dotenv": "^17.2.3", "tailwindcss": "^4", + "tsx": "^4.20.6", "tw-animate-css": "^1.3.7", "typescript": "^5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43474ca..c660d62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.1.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-navigation-menu': specifier: ^1.2.14 version: 1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -35,9 +38,21 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 better-sqlite3: specifier: ^12.4.1 version: 12.4.1 @@ -53,6 +68,9 @@ importers: cobe: specifier: ^0.6.5 version: 0.6.5 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 lucide-react: specifier: ^0.540.0 version: 0.540.0(react@19.1.0) @@ -68,6 +86,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: + specifier: ^8.16.3 + version: 8.16.3 react: specifier: 19.1.0 version: 19.1.0 @@ -80,6 +101,9 @@ importers: recharts: specifier: ^3.3.0 version: 3.3.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react-is@19.2.0)(react@19.1.0)(redux@5.0.1) + redis: + specifier: ^5.9.0 + version: 5.9.0 sanitize-html: specifier: ^2.17.0 version: 2.17.0 @@ -114,9 +138,15 @@ importers: '@types/validator': specifier: ^13.15.3 version: 13.15.3 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 tailwindcss: specifier: ^4 version: 4.1.15 + tsx: + specifier: ^4.20.6 + version: 4.20.6 tw-animate-css: specifier: ^1.3.7 version: 1.4.0 @@ -186,6 +216,162 @@ packages: '@emnapi/runtime@1.6.0': resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -582,6 +768,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -805,6 +1004,34 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@5.9.0': + resolution: {integrity: sha512-W9D8yfKTWl4tP8lkC3MRYkMz4OfbuzE/W8iObe0jFgoRmgMfkBV+Vj38gvIqZPImtY0WB34YZkX3amYuQebvRQ==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.9.0 + + '@redis/client@5.9.0': + resolution: {integrity: sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==} + engines: {node: '>= 18'} + + '@redis/json@5.9.0': + resolution: {integrity: sha512-Bm2jjLYaXdUWPb9RaEywxnjmzw7dWKDZI4MS79mTWPV16R982jVWBj6lY2ZGelJbwxHtEVg4/FSVgYDkuO/MxA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.9.0 + + '@redis/search@5.9.0': + resolution: {integrity: sha512-jdk2csmJ29DlpvCIb2ySjix2co14/0iwIT3C0I+7ZaToXgPbgBMB+zfEilSuncI2F9JcVxHki0YtLA0xX3VdpA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.9.0 + + '@redis/time-series@5.9.0': + resolution: {integrity: sha512-W6ILxcyOqhnI7ELKjJXOktIg3w4+aBHugDbVpgVLPZ+YDjObis1M0v7ZzwlpXhlpwsfePfipeSK+KWNuymk52w==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.9.0 + '@reduxjs/toolkit@2.9.2': resolution: {integrity: sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==} peerDependencies: @@ -916,6 +1143,9 @@ packages: '@tailwindcss/postcss@4.1.15': resolution: {integrity: sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} @@ -946,9 +1176,18 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.23': resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: @@ -973,6 +1212,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + better-sqlite3@12.4.1: resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==} engines: {node: 20.x || 22.x || 23.x || 24.x} @@ -983,6 +1226,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1005,6 +1251,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cobe@0.6.5: resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==} @@ -1093,6 +1343,13 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1107,6 +1364,11 @@ packages: es-toolkit@1.40.0: resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1138,10 +1400,18 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -1181,6 +1451,16 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1251,6 +1531,27 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1293,6 +1594,9 @@ packages: react-dom: optional: true + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1346,6 +1650,14 @@ packages: resolution: {integrity: sha512-Pr/5KdBQGG8TirdkS0qN3B+f3eo8zTOfZQWAxHoJqopMz2/uvRnG+S4fWu/6AZxKei2CP2p/psdQ5HFC2Ap5BA==} engines: {node: '>=10'} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1356,6 +1668,40 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + phenomenon@1.6.0: resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==} @@ -1370,6 +1716,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -1462,6 +1824,10 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redis@5.9.0: + resolution: {integrity: sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==} + engines: {node: '>= 18'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -1473,6 +1839,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1501,6 +1870,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1544,6 +1917,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -1601,6 +1979,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} @@ -1648,6 +2030,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -1994,6 +2454,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -2210,6 +2679,26 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/bloom@5.9.0(@redis/client@5.9.0)': + dependencies: + '@redis/client': 5.9.0 + + '@redis/client@5.9.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.9.0(@redis/client@5.9.0)': + dependencies: + '@redis/client': 5.9.0 + + '@redis/search@5.9.0(@redis/client@5.9.0)': + dependencies: + '@redis/client': 5.9.0 + + '@redis/time-series@5.9.0(@redis/client@5.9.0)': + dependencies: + '@redis/client': 5.9.0 + '@reduxjs/toolkit@2.9.2(react-redux@9.2.0(@types/react@19.2.2)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': dependencies: '@standard-schema/spec': 1.0.0 @@ -2301,6 +2790,10 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.15 + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 20.19.23 + '@types/canvas-confetti@1.9.0': {} '@types/d3-array@3.2.2': {} @@ -2327,10 +2820,23 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.23 + + '@types/ms@2.1.0': {} + '@types/node@20.19.23': dependencies: undici-types: 6.21.0 + '@types/pg@8.15.6': + dependencies: + '@types/node': 20.19.23 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -2353,6 +2859,11 @@ snapshots: base64-js@1.5.1: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + better-sqlite3@12.4.1: dependencies: bindings: 1.5.0 @@ -2368,6 +2879,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + buffer-equal-constant-time@1.0.1: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -2387,6 +2900,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cobe@0.6.5: dependencies: phenomenon: 1.6.0 @@ -2465,6 +2980,12 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@17.2.3: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2478,6 +2999,35 @@ snapshots: es-toolkit@1.40.0: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escape-string-regexp@4.0.0: {} eventemitter3@5.0.1: {} @@ -2497,8 +3047,15 @@ snapshots: fs-constants@1.0.0: {} + fsevents@2.3.3: + optional: true + get-nonce@1.0.1: {} + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} graceful-fs@4.2.11: {} @@ -2533,6 +3090,30 @@ snapshots: js-tokens@4.0.0: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + lightningcss-android-arm64@1.30.2: optional: true @@ -2582,6 +3163,20 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2614,6 +3209,8 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + ms@2.1.3: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} @@ -2662,6 +3259,10 @@ snapshots: dependencies: semver: 7.7.3 + node-addon-api@8.5.0: {} + + node-gyp-build@4.8.4: {} + object-assign@4.1.1: {} once@1.4.0: @@ -2670,6 +3271,41 @@ snapshots: parse-srcset@1.0.2: {} + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + phenomenon@1.6.0: {} picocolors@1.1.1: {} @@ -2686,6 +3322,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -2800,6 +3446,14 @@ snapshots: - '@types/react' - redux + redis@5.9.0: + dependencies: + '@redis/bloom': 5.9.0(@redis/client@5.9.0) + '@redis/client': 5.9.0 + '@redis/json': 5.9.0(@redis/client@5.9.0) + '@redis/search': 5.9.0(@redis/client@5.9.0) + '@redis/time-series': 5.9.0(@redis/client@5.9.0) + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -2808,6 +3462,8 @@ snapshots: reselect@5.1.1: {} + resolve-pkg-maps@1.0.0: {} + safe-buffer@5.2.1: {} sanitize-html@2.17.0: @@ -2863,6 +3519,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -2899,6 +3557,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -2958,4 +3623,6 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + zod@4.1.12: {} diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..d1d1c60 --- /dev/null +++ b/prompt.md @@ -0,0 +1,4 @@ +planeje e vamos fazer a tela de market deverá ter um botão que permitirá o usuario adicionar um anuncio para vender. +ele poderá selecionar qualquer um dos itens que temos na library. +e poderá colocar preço mínimo desejado. +vamos fazer uma telemetria completa de vários dados de venda e compra, datas, horários, quantos cliques o anuncio do usuario teve, quanto tempo o usuario ficou no anuncio, e etc para vender esses dados em um plano no futuro.