refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/shared/lib/auth/config";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,85 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.id, payload.tokenId),
eq(tokensApi.userId, payload.sub),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar hash do token e último uso
await db
.update(tokensApi)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip"),
expiresAt: result.expiresAt,
})
.where(eq(tokensApi.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,71 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { tokensApi } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/shared/lib/auth/api-token";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(tokensApi).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
interface RouteParams {
params: Promise<{ tokenId: string }>;
}
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.id, tokenId),
eq(tokensApi.userId, session.user.id),
),
});
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 },
);
}
// Revogar token (soft delete)
await db
.update(tokensApi)
.set({ revokedAt: new Date() })
.where(eq(tokensApi.id, tokenId));
return NextResponse.json({
message: "Token revogado com sucesso",
tokenId,
});
} catch (error) {
console.error("[API] Error revoking device token:", error);
return NextResponse.json(
{ error: "Erro ao revogar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,42 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
export async function GET() {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const activeTokens = await db
.select({
id: tokensApi.id,
name: tokensApi.name,
tokenPrefix: tokensApi.tokenPrefix,
lastUsedAt: tokensApi.lastUsedAt,
lastUsedIp: tokensApi.lastUsedIp,
expiresAt: tokensApi.expiresAt,
createdAt: tokensApi.createdAt,
})
.from(tokensApi)
.where(
and(eq(tokensApi.userId, session.user.id), isNull(tokensApi.revokedAt)),
)
.orderBy(desc(tokensApi.createdAt));
return NextResponse.json({ tokens: activeTokens });
} catch (error) {
console.error("[API] Error listing device tokens:", error);
return NextResponse.json(
{ error: "Erro ao listar tokens" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,73 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ valid: false, error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Atualizar último uso
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
return NextResponse.json({
valid: true,
userId: tokenRecord.userId,
tokenId: tokenRecord.id,
tokenName: tokenRecord.name,
});
} catch (error) {
console.error("[API] Error verifying device token:", error);
return NextResponse.json(
{ valid: false, error: "Erro ao validar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { db } from "@/shared/lib/db";
const APP_VERSION = "1.0.0";
/**
* Health check endpoint para Docker, monitoring e OpenMonetis Companion
* GET /api/health
*
* Retorna status 200 se a aplicação está saudável
* Verifica conexão com banco de dados
* Usado pelo app Android para validar URL do servidor
*/
export async function GET() {
try {
// Tenta fazer uma query simples no banco para verificar conexão
// Isso garante que o app está conectado ao banco antes de considerar "healthy"
await db.execute("SELECT 1");
return NextResponse.json(
{
status: "ok",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
);
} catch (error) {
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
console.error("Health check failed:", error);
return NextResponse.json(
{
status: "error",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: "Database connection failed",
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,163 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { preLancamentos, tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
import { inboxBatchSchema } from "@/shared/lib/schemas/inbox";
// Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 20; // 20 batch requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
}
interface BatchResult {
clientId?: string;
serverId?: string;
success: boolean;
error?: string;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar cada item
const results: BatchResult[] = [];
for (const item of items) {
try {
const [inserted] = await db
.insert(preLancamentos)
.values({
userId: tokenRecord.userId,
sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName,
originalTitle: item.originalTitle,
originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(),
status: "pending",
})
.returning({ id: preLancamentos.id });
results.push({
clientId: item.clientId,
serverId: inserted.id,
success: true,
});
} catch (error) {
results.push({
clientId: item.clientId,
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
}
}
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
return NextResponse.json(
{
message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
total: items.length,
success: successCount,
failed: failCount,
results,
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ status: 500 },
);
}
}

133
src/app/api/inbox/route.ts Normal file
View File

@@ -0,0 +1,133 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { preLancamentos, tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
import { inboxItemSchema } from "@/shared/lib/schemas/inbox";
// Rate limiting simples em memória (em produção, use Redis)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 100; // 100 requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const data = inboxItemSchema.parse(body);
// Inserir item na inbox
const [inserted] = await db
.insert(preLancamentos)
.values({
userId: tokenRecord.userId,
sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName,
originalTitle: data.originalTitle,
originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(),
status: "pending",
})
.returning({ id: preLancamentos.id });
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
return NextResponse.json(
{
id: inserted.id,
clientId: data.clientId,
message: "Notificação recebida",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ status: 500 },
);
}
}