perf: otimizar dashboard com indexes, cache e consolidação de queries (v1.3.0)

- Adicionar indexes compostos em lancamentos para queries frequentes
- Eliminar ~20 JOINs com pagadores via helper cacheado getAdminPagadorId()
- Consolidar queries: income-expense-balance (12→1), payment-status (2→1), categories (4→2)
- Adicionar cache cross-request via unstable_cache com tag-based invalidation
- Limitar scan de métricas a 24 meses
- Deduplicar auth session por request via React.cache()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-06 12:24:15 +00:00
parent 21fac52e28
commit 6f5c41a4cf
45 changed files with 3589 additions and 1219 deletions

View File

@@ -1,4 +1,4 @@
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { z } from "zod";
import { getUser } from "@/lib/auth/server";
import type { ActionResult } from "./types";
@@ -35,14 +35,30 @@ export const revalidateConfig = {
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const;
/** Entities whose mutations should invalidate the dashboard cache */
const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"lancamentos",
"contas",
"cartoes",
"orcamentos",
"pagadores",
"inbox",
]);
/**
* Revalidates paths for a specific entity
* Revalidates paths for a specific entity.
* Also invalidates the dashboard "use cache" tag for financial entities.
* @param entity - The entity type
*/
export function revalidateForEntity(
entity: keyof typeof revalidateConfig,
): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path));
// Invalidate dashboard cache for financial mutations
if (DASHBOARD_ENTITIES.has(entity)) {
revalidateTag("dashboard");
}
}
/**

View File

@@ -1,14 +1,23 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/lib/auth/config";
/**
* Cached session fetch - deduplicates auth calls within a single request.
* Layout + page calling getUser() will only hit auth once.
*/
const getSessionCached = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
/**
* Gets the current authenticated user
* @returns User object
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -23,7 +32,7 @@ export async function getUser() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -38,7 +47,7 @@ export async function getUserId() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -53,5 +62,5 @@ export async function getUserSession() {
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() });
return getSessionCached();
}

View File

@@ -1,9 +1,10 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -51,6 +52,11 @@ export async function fetchDashboardBoletos(
userId: string,
period: string,
): Promise<DashboardBoletosSnapshot> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
.select({
id: lancamentos.id,
@@ -61,13 +67,12 @@ export async function fetchDashboardBoletos(
isSettled: lancamentos.isSettled,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(pagadores.role, "admin"),
eq(lancamentos.pagadorId, adminPagadorId),
),
)
.orderBy(

View File

@@ -1,9 +1,10 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type CategoryExpenseItem = {
@@ -24,138 +25,129 @@ export type ExpensesByCategoryData = {
previousTotal: number;
};
const calculatePercentageChange = (
current: number,
previous: number,
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return null;
return current > 0 ? 100 : -100;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
};
export async function fetchExpensesByCategory(
userId: string,
period: string,
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca despesas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(
orcamentos,
and(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId),
),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
orcamentos.amount,
);
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Busca despesas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(categorias.id);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
for (const row of previousPeriodRows) {
const amount = Math.abs(toNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calcula o total do período atual
// Calculate totals
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(toNumber(row.total));
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Monta os dados de cada categoria
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(toNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
// Build result
const categories: CategoryExpenseItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount,
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
? (entry.current / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);

View File

@@ -1,17 +1,11 @@
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import {
categorias,
contas,
lancamentos,
orcamentos,
pagadores,
} from "@/db/schema";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
@@ -40,131 +34,130 @@ export async function fetchIncomeByCategory(
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca receitas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(
orcamentos,
and(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId),
),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
orcamentos.amount,
);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Busca receitas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
// Single query: GROUP BY categoriaId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
),
)
.groupBy(categorias.id);
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, safeToNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
for (const row of previousPeriodRows) {
const amount = Math.abs(safeToNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calcula o total do período atual
// Calculate totals
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(safeToNumber(row.total));
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Monta os dados de cada categoria
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(safeToNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
// Build result
const categories: CategoryIncomeItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount,
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount
? safeToNumber(row.budgetAmount)
: null;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
? (entry.current / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type InstallmentExpense = {
id: string;
@@ -28,6 +28,11 @@ export async function fetchInstallmentExpenses(
userId: string,
period: string,
): Promise<InstallmentExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const rows = await db
.select({
id: lancamentos.id,
@@ -41,7 +46,6 @@ export async function fetchInstallmentExpenses(
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -49,7 +53,7 @@ export async function fetchInstallmentExpenses(
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type RecurringExpense = {
id: string;
@@ -24,6 +24,11 @@ export async function fetchRecurringExpenses(
userId: string,
period: string,
): Promise<RecurringExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const results = await db
.select({
id: lancamentos.id,
@@ -33,14 +38,13 @@ export async function fetchRecurringExpenses(
recurrenceCount: lancamentos.recurrenceCount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Recorrente"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type TopExpense = {
id: string;
@@ -26,11 +26,16 @@ export async function fetchTopExpenses(
period: string,
cardOnly: boolean = false,
): Promise<TopExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const conditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
@@ -60,7 +65,6 @@ export async function fetchTopExpenses(
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(and(...conditions))

View File

@@ -1,3 +1,4 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
@@ -17,7 +18,7 @@ import { fetchPurchasesByCategory } from "./purchases-by-category";
import { fetchRecentTransactions } from "./recent-transactions";
import { fetchTopEstablishments } from "./top-establishments";
export async function fetchDashboardData(userId: string, period: string) {
async function fetchDashboardDataInternal(userId: string, period: string) {
const [
metrics,
accountsSnapshot,
@@ -83,4 +84,20 @@ export async function fetchDashboardData(userId: string, period: string) {
};
}
/**
* Cached dashboard data fetcher.
* Uses unstable_cache with tags for revalidation on mutations.
* Cache is keyed by userId + period, and invalidated via "dashboard" tag.
*/
export function fetchDashboardData(userId: string, period: string) {
return unstable_cache(
() => fetchDashboardDataInternal(userId, period),
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
revalidate: 120,
},
)();
}
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

@@ -1,11 +1,12 @@
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type MonthData = {
month: string;
@@ -66,83 +67,67 @@ export async function fetchIncomeExpenseBalance(
userId: string,
currentPeriod: string,
): Promise<IncomeExpenseBalanceData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { months: [] };
}
const periods = generateLast6Months(currentPeriod);
const results = await Promise.all(
periods.map(async (period) => {
// Busca receitas do período
const [incomeRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
);
// Single query: GROUP BY period + transactionType instead of 12 separate queries
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
// Busca despesas do período
const [expenseRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
// Build lookup from query results
const dataMap = new Map<string, { income: number; expense: number }>();
for (const row of rows) {
if (!row.period) continue;
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 };
const total = Math.abs(toNumber(row.total));
if (row.transactionType === "Receita") {
entry.income = total;
} else if (row.transactionType === "Despesa") {
entry.expense = total;
}
dataMap.set(row.period, entry);
}
const income = Math.abs(toNumber(incomeRow?.total));
const expense = Math.abs(toNumber(expenseRow?.total));
const balance = income - expense;
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,
};
});
return {
month: period,
monthLabel: monthLabel ?? "",
income,
expense,
balance,
};
}),
);
return {
months: results,
};
return { months };
}

View File

@@ -2,6 +2,7 @@ import {
and,
asc,
eq,
gte,
ilike,
isNull,
lte,
@@ -10,15 +11,16 @@ import {
or,
sum,
} from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
comparePeriods,
getPreviousPeriod,
@@ -80,6 +82,21 @@ export async function fetchDashboardCardMetrics(
): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return {
period,
previousPeriod,
receitas: { current: 0, previous: 0 },
despesas: { current: 0, previous: 0 },
balanco: { current: 0, previous: 0 },
previsto: { current: 0, previous: 0 },
};
}
// Limitar scan histórico a 24 meses para evitar scans progressivamente mais lentos
const startPeriod = addMonthsToPeriod(period, -24);
const rows = await db
.select({
period: lancamentos.period,
@@ -87,13 +104,13 @@ export async function fetchDashboardCardMetrics(
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
@@ -129,12 +146,12 @@ export async function fetchDashboardCardMetrics(
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startPeriod =
const startRangePeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const periodRange = buildPeriodRange(startPeriod, period);
const periodRange = buildPeriodRange(startRangePeriod, period);
const forecastByPeriod = new Map<string, number>();
let runningForecast = 0;

View File

@@ -1,9 +1,10 @@
"use server";
import { and, eq, lt, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type NotificationType = "overdue" | "due_soon";
@@ -138,6 +139,8 @@ export async function fetchDashboardNotifications(
const today = normalizeDate(new Date());
const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
// Buscar faturas pendentes de períodos anteriores
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
const overdueInvoices = await db
@@ -210,7 +213,17 @@ export async function fetchDashboardNotifications(
faturas.paymentStatus,
);
// Buscar boletos não pagos
// Buscar boletos não pagos (usando pagadorId direto ao invés de JOIN)
const boletosConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
];
if (adminPagadorId) {
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
}
const boletosRows = await db
.select({
id: lancamentos.id,
@@ -220,15 +233,7 @@ export async function fetchDashboardNotifications(
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
eq(pagadores.role, "admin"),
),
);
.where(and(...boletosConditions));
const notifications: DashboardNotification[] = [];

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type PaymentConditionSummary = {
condition: string;
@@ -23,6 +23,11 @@ export async function fetchPaymentConditions(
userId: string,
period: string,
): Promise<PaymentConditionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { conditions: [] };
}
const rows = await db
.select({
condition: lancamentos.condition,
@@ -30,13 +35,12 @@ export async function fetchPaymentConditions(
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type PaymentMethodSummary = {
paymentMethod: string;
@@ -23,6 +23,11 @@ export async function fetchPaymentMethods(
userId: string,
period: string,
): Promise<PaymentMethodsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { methods: [] };
}
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
@@ -30,13 +35,12 @@ export async function fetchPaymentMethods(
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,8 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type PaymentStatusCategory = {
total: number;
@@ -15,106 +16,67 @@ export type PaymentStatusData = {
expenses: PaymentStatusCategory;
};
const emptyCategory = (): PaymentStatusCategory => ({
total: 0,
confirmed: 0,
pending: 0,
});
export async function fetchPaymentStatus(
userId: string,
period: string,
): Promise<PaymentStatusData> {
// Busca receitas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const incomeResult = await db
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { income: emptyCategory(), expenses: emptyCategory() };
}
// Single query: GROUP BY transactionType instead of 2 separate queries
const rows = await db
.select({
transactionType: lancamentos.transactionType,
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
coalesce(
sum(case when ${lancamentos.isSettled} = true then ${lancamentos.amount} else 0 end),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
coalesce(
sum(case when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount} else 0 end),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
)
.groupBy(lancamentos.transactionType);
// Busca despesas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const expensesResult = await db
.select({
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
const result = { income: emptyCategory(), expenses: emptyCategory() };
const incomeData = incomeResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedIncome = toNumber(incomeData.confirmed);
const pendingIncome = toNumber(incomeData.pending);
for (const row of rows) {
const confirmed = toNumber(row.confirmed);
const pending = toNumber(row.pending);
const category = {
total: confirmed + pending,
confirmed,
pending,
};
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedExpenses = toNumber(expensesData.confirmed);
const pendingExpenses = toNumber(expensesData.pending);
if (row.transactionType === "Receita") {
result.income = category;
} else if (row.transactionType === "Despesa") {
result.expenses = category;
}
}
return {
income: {
total: confirmedIncome + pendingIncome,
confirmed: confirmedIncome,
pending: pendingIncome,
},
expenses: {
total: confirmedExpenses + pendingExpenses,
confirmed: confirmedExpenses,
pending: pendingExpenses,
},
};
return result;
}

View File

@@ -1,18 +1,12 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type CategoryOption = {
id: string;
@@ -51,6 +45,11 @@ export async function fetchPurchasesByCategory(
userId: string,
period: string,
): Promise<PurchasesByCategoryData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], transactionsByCategory: {} };
}
const transactionsRows = await db
.select({
id: lancamentos.id,
@@ -64,7 +63,6 @@ export async function fetchPurchasesByCategory(
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
@@ -72,7 +70,7 @@ export async function fetchPurchasesByCategory(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(categorias.type, ["despesa", "receita"]),
or(
isNull(lancamentos.note),

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type RecentTransaction = {
id: string;
@@ -25,6 +25,11 @@ export async function fetchRecentTransactions(
userId: string,
period: string,
): Promise<RecentTransactionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { transactions: [] };
}
const results = await db
.select({
id: lancamentos.id,
@@ -36,7 +41,6 @@ export async function fetchRecentTransactions(
note: lancamentos.note,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
@@ -44,7 +48,7 @@ export async function fetchRecentTransactions(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type TopEstablishment = {
id: string;
@@ -38,6 +38,11 @@ export async function fetchTopEstablishments(
userId: string,
period: string,
): Promise<TopEstablishmentsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { establishments: [] };
}
const rows = await db
.select({
name: lancamentos.name,
@@ -46,7 +51,6 @@ export async function fetchTopEstablishments(
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
@@ -54,7 +58,7 @@ export async function fetchTopEstablishments(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -0,0 +1,25 @@
import { and, eq } from "drizzle-orm";
import { cache } from "react";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
/**
* Returns the admin pagador ID for a user (cached per request via React.cache).
* Eliminates the need for JOIN with pagadores in ~20 dashboard queries.
*/
export const getAdminPagadorId = cache(
async (userId: string): Promise<string | null> => {
const [row] = await db
.select({ id: pagadores.id })
.from(pagadores)
.where(
and(
eq(pagadores.userId, userId),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
)
.limit(1);
return row?.id ?? null;
},
);