refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

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

View File

@@ -5,81 +5,88 @@
* Usado pelo app Android quando o access token expira.
*/
import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/lib/auth/api-token";
import { db } from "@/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
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 }
);
}
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
// 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 }
);
}
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.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt)
),
});
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 }
);
}
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 }
);
}
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(apiTokens)
.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(apiTokens.id, payload.tokenId));
// Atualizar hash do token e último uso
await db
.update(apiTokens)
.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(apiTokens.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 }
);
}
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

@@ -5,75 +5,74 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/lib/auth/api-token";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
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() });
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 }
);
}
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);
// 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
);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Salvar hash do token no banco
await db.insert(apiTokens).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 }
);
}
// 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 }
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -5,61 +5,58 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
interface RouteParams {
params: Promise<{ tokenId: string }>;
params: Promise<{ tokenId: string }>;
}
export async function DELETE(request: Request, { params }: RouteParams) {
try {
const { tokenId } = await params;
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() });
// 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 }
);
}
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, tokenId),
eq(apiTokens.userId, session.user.id)
),
});
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, tokenId),
eq(apiTokens.userId, session.user.id),
),
});
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 }
);
}
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 },
);
}
// Revogar token (soft delete)
await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.id, tokenId));
// Revogar token (soft delete)
await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.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 }
);
}
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

@@ -5,49 +5,48 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
export async function GET() {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
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 }
);
}
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const tokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
expiresAt: apiTokens.expiresAt,
createdAt: apiTokens.createdAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
// Buscar tokens ativos do usuário
const tokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
expiresAt: apiTokens.expiresAt,
createdAt: apiTokens.createdAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
// Separar tokens ativos e revogados
const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date());
// Separar tokens ativos e revogados
const activeTokens = tokens.filter(
(t) => !t.expiresAt || new Date(t.expiresAt) > new Date(),
);
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 }
);
}
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

@@ -7,75 +7,76 @@
* Aceita tokens no formato os_xxx (hash-based, sem expiração).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
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 }
);
}
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 }
);
}
// 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);
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt)
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 }
);
}
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;
// Atualizar último uso
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.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 }
);
}
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

@@ -12,33 +12,34 @@ const APP_VERSION = "1.0.0";
* 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");
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: "OpenSheets",
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: "ok",
name: "OpenSheets",
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: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: error instanceof Error ? error.message : "Database connection failed",
},
{ status: 503 }
);
}
return NextResponse.json(
{
status: "error",
name: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message:
error instanceof Error ? error.message : "Database connection failed",
},
{ status: 503 },
);
}
}

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { inboxBatchSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
@@ -19,153 +19,153 @@ 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);
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 || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
userLimit.count++;
return true;
}
interface BatchResult {
clientId?: string;
serverId?: string;
success: boolean;
error?: string;
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);
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 },
);
}
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 },
);
}
// 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);
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
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 },
);
}
// 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);
// Validar body
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar cada item
const results: BatchResult[] = [];
// Processar cada item
const results: BatchResult[] = [];
for (const item of items) {
try {
const [inserted] = await db
.insert(inboxItems)
.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(),
parsedTransactionType: item.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
for (const item of items) {
try {
const [inserted] = await db
.insert(inboxItems)
.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(),
parsedTransactionType: item.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.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",
});
}
}
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;
// 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(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
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 },
);
}
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 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ status: 500 },
);
}
}

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { inboxItemSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória (em produção, use Redis)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
@@ -19,123 +19,123 @@ 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);
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 || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
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);
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 },
);
}
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 },
);
}
// 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);
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
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 },
);
}
// 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);
// Validar body
const body = await request.json();
const data = inboxItemSchema.parse(body);
// Inserir item na inbox
const [inserted] = await db
.insert(inboxItems)
.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(),
parsedTransactionType: data.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
// Inserir item na inbox
const [inserted] = await db
.insert(inboxItems)
.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(),
parsedTransactionType: data.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
// 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(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.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 },
);
}
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 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ status: 500 },
);
}
}