mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-15 13:01:47 +00:00
refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets
Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/ foram movidos para subdiretórios temáticos (bills/, invoices/, notes/, notifications/, overview/, payments/, goals-progress/, categories/). ~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura baseada em widget-registry com components/widgets/. Novos componentes: category-breakdown-chart/list, goals-progress-item, percentage-change-indicator. Imports atualizados em fetch-dashboard-data e transaction-filters limpos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
577
src/features/dashboard/notifications/notifications-queries.ts
Normal file
577
src/features/dashboard/notifications/notifications-queries.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import { and, eq, gte, inArray, isNotNull, lt, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
budgets,
|
||||
cards,
|
||||
categories,
|
||||
dashboardNotificationStates,
|
||||
invoices,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices/invoices-helpers";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import type {
|
||||
BudgetNotification,
|
||||
DashboardNotification,
|
||||
DashboardNotificationsSnapshot,
|
||||
} from "@/shared/lib/types/notifications";
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
getBusinessDateString,
|
||||
isDateOnlyPast,
|
||||
isDateOnlyWithinDays,
|
||||
toDateOnlyString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
formatPeriodForUrl,
|
||||
getNextPeriod,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
export type {
|
||||
BudgetNotification,
|
||||
BudgetStatus,
|
||||
DashboardNotification,
|
||||
DashboardNotificationsSnapshot,
|
||||
NotificationType,
|
||||
} from "@/shared/lib/types/notifications";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||
|
||||
type PersistedNotificationState = {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
const buildInvoiceNotificationKey = (cardId: string, period: string) =>
|
||||
`invoice-${cardId}-${period}`;
|
||||
|
||||
const buildBoletoNotificationKey = (transactionId: string) =>
|
||||
`boleto-${transactionId}`;
|
||||
|
||||
const buildBudgetNotificationKey = (
|
||||
categoryId: string | null,
|
||||
budgetId: string,
|
||||
period: string,
|
||||
) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`);
|
||||
|
||||
function mergeNotificationState<
|
||||
T extends {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
},
|
||||
>(items: T[], stateByKey: Map<string, PersistedNotificationState>): T[] {
|
||||
return items.map((item) => {
|
||||
const persisted = stateByKey.get(item.notificationKey);
|
||||
|
||||
if (!persisted || persisted.fingerprint !== item.fingerprint) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
isRead: persisted.readAt !== null,
|
||||
isArchived: persisted.archivedAt !== null,
|
||||
readAt: persisted.readAt,
|
||||
archivedAt: persisted.archivedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca todas as notificações do dashboard:
|
||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||
* - Boletos não pagos atrasados ou com vencimento próximo
|
||||
* - Orçamentos excedidos (≥ 100%) ou críticos (≥ 80%)
|
||||
*/
|
||||
export async function fetchDashboardNotifications(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<DashboardNotificationsSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const DAYS_THRESHOLD = 5;
|
||||
const nextPeriod = getNextPeriod(currentPeriod);
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
|
||||
// --- Build conditions that depend on adminPayerId ---
|
||||
const boletosConditions = [
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(transactions.isSettled, false),
|
||||
];
|
||||
if (adminPayerId) {
|
||||
boletosConditions.push(eq(transactions.payerId, adminPayerId));
|
||||
}
|
||||
boletosConditions.push(isNotNull(transactions.dueDate));
|
||||
boletosConditions.push(
|
||||
gte(transactions.period, addMonthsToPeriod(currentPeriod, -12)),
|
||||
);
|
||||
|
||||
const budgetJoinConditions = [
|
||||
eq(transactions.categoryId, budgets.categoryId),
|
||||
eq(transactions.userId, budgets.userId),
|
||||
eq(transactions.period, budgets.period),
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
ne(transactions.condition, "cancelado"),
|
||||
];
|
||||
if (adminPayerId) {
|
||||
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
|
||||
}
|
||||
|
||||
// Helper: monta a query de faturas por período (reutilizada para período atual e próximo)
|
||||
const buildPeriodInvoicesQuery = (period: string) =>
|
||||
db
|
||||
.select({
|
||||
invoiceId: invoices.id,
|
||||
cardId: cards.id,
|
||||
cardName: cards.name,
|
||||
cardLogo: cards.logo,
|
||||
dueDay: cards.dueDay,
|
||||
period: sql<string>`COALESCE(${invoices.period}, ${period})`,
|
||||
paymentStatus: invoices.paymentStatus,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${transactions.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
||||
})
|
||||
.from(cards)
|
||||
.leftJoin(
|
||||
invoices,
|
||||
and(
|
||||
eq(invoices.cardId, cards.id),
|
||||
eq(invoices.userId, userId),
|
||||
eq(invoices.period, period),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactions.cardId, cards.id),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
),
|
||||
)
|
||||
.where(eq(cards.userId, userId))
|
||||
.groupBy(
|
||||
invoices.id,
|
||||
cards.id,
|
||||
cards.name,
|
||||
cards.logo,
|
||||
cards.dueDay,
|
||||
invoices.period,
|
||||
invoices.paymentStatus,
|
||||
);
|
||||
|
||||
// --- All 5 queries are independent — run in parallel ---
|
||||
const [
|
||||
overdueInvoices,
|
||||
currentInvoices,
|
||||
nextPeriodInvoices,
|
||||
boletosRows,
|
||||
budgetRows,
|
||||
] = await Promise.all([
|
||||
// Faturas atrasadas (períodos anteriores)
|
||||
db
|
||||
.select({
|
||||
invoiceId: invoices.id,
|
||||
cardId: cards.id,
|
||||
cardName: cards.name,
|
||||
cardLogo: cards.logo,
|
||||
dueDay: cards.dueDay,
|
||||
period: invoices.period,
|
||||
totalAmount: sql<
|
||||
number | null
|
||||
>`COALESCE(SUM(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(invoices)
|
||||
.innerJoin(cards, eq(invoices.cardId, cards.id))
|
||||
.leftJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactions.cardId, invoices.cardId),
|
||||
eq(transactions.period, invoices.period),
|
||||
eq(transactions.userId, invoices.userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.userId, userId),
|
||||
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
||||
lt(invoices.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
invoices.id,
|
||||
cards.id,
|
||||
cards.name,
|
||||
cards.logo,
|
||||
cards.dueDay,
|
||||
invoices.period,
|
||||
),
|
||||
// Faturas do período atual e próximo
|
||||
buildPeriodInvoicesQuery(currentPeriod),
|
||||
buildPeriodInvoicesQuery(nextPeriod),
|
||||
// Boletos não pagos
|
||||
db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
dueDate: transactions.dueDate,
|
||||
period: transactions.period,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(and(...boletosConditions)),
|
||||
// Orçamentos do período atual
|
||||
db
|
||||
.select({
|
||||
orcamentoId: budgets.id,
|
||||
categoryId: budgets.categoryId,
|
||||
budgetAmount: budgets.amount,
|
||||
period: budgets.period,
|
||||
categoriaName: categories.name,
|
||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
||||
})
|
||||
.from(budgets)
|
||||
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
||||
.leftJoin(transactions, and(...budgetJoinConditions))
|
||||
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
|
||||
.groupBy(budgets.id, budgets.amount, categories.name),
|
||||
]);
|
||||
|
||||
// =====================
|
||||
// Processar notificações
|
||||
// =====================
|
||||
|
||||
const notifications: DashboardNotification[] = [];
|
||||
|
||||
// Faturas atrasadas (períodos anteriores)
|
||||
for (const invoice of overdueInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||
invoice.period,
|
||||
invoice.dueDay,
|
||||
);
|
||||
if (!dueDate) continue;
|
||||
const amount = toNumber(invoice.totalAmount);
|
||||
const notificationKey = buildInvoiceNotificationKey(
|
||||
invoice.cardId,
|
||||
invoice.period,
|
||||
);
|
||||
|
||||
notifications.push({
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: true,
|
||||
cardLogo: invoice.cardLogo,
|
||||
notificationKey,
|
||||
fingerprint: "overdue",
|
||||
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Faturas do período atual
|
||||
for (const invoice of currentInvoices) {
|
||||
if (!invoice.period || !invoice.dueDay) continue;
|
||||
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||
invoice.period,
|
||||
invoice.dueDay,
|
||||
);
|
||||
if (!dueDate) continue;
|
||||
const amount = toNumber(invoice.totalAmount);
|
||||
const transactionCount = toNumber(invoice.transactionCount);
|
||||
const paymentStatus =
|
||||
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
|
||||
|
||||
const shouldInclude =
|
||||
transactionCount > 0 ||
|
||||
Math.abs(amount) > 0 ||
|
||||
invoice.invoiceId !== null;
|
||||
if (!shouldInclude) continue;
|
||||
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||
|
||||
const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
|
||||
const invoiceIsDueSoon = isDateOnlyWithinDays(
|
||||
dueDate,
|
||||
DAYS_THRESHOLD,
|
||||
today,
|
||||
);
|
||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||
|
||||
const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
|
||||
const notificationKey = buildInvoiceNotificationKey(
|
||||
invoice.cardId,
|
||||
invoice.period,
|
||||
);
|
||||
|
||||
notifications.push({
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: notificationStatus,
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: invoiceIsOverdue,
|
||||
cardLogo: invoice.cardLogo,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Faturas do próximo período com vencimento próximo
|
||||
const addedNotificationKeys = new Set(
|
||||
notifications.map((n) => n.notificationKey),
|
||||
);
|
||||
for (const invoice of nextPeriodInvoices) {
|
||||
if (!invoice.dueDay) continue;
|
||||
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||
nextPeriod,
|
||||
invoice.dueDay,
|
||||
);
|
||||
if (!dueDate) continue;
|
||||
if (invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||
|
||||
const invoiceIsDueSoon = isDateOnlyWithinDays(
|
||||
dueDate,
|
||||
DAYS_THRESHOLD,
|
||||
today,
|
||||
);
|
||||
if (!invoiceIsDueSoon) continue;
|
||||
|
||||
const notificationKey = buildInvoiceNotificationKey(
|
||||
invoice.cardId,
|
||||
nextPeriod,
|
||||
);
|
||||
// Evitar duplicata se já foi adicionado via currentInvoices
|
||||
if (addedNotificationKeys.has(notificationKey)) continue;
|
||||
|
||||
const amount = toNumber(invoice.totalAmount);
|
||||
notifications.push({
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: nextPeriod,
|
||||
showAmount: false,
|
||||
cardLogo: invoice.cardLogo,
|
||||
notificationKey,
|
||||
fingerprint: "due_soon",
|
||||
href: buildInvoiceDetailsHref(invoice.cardId, nextPeriod),
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Boletos
|
||||
for (const boleto of boletosRows) {
|
||||
const dueDate = toDateOnlyString(boleto.dueDate);
|
||||
if (!dueDate) continue;
|
||||
|
||||
const boletoIsOverdue = isDateOnlyPast(dueDate, today);
|
||||
const boletoIsDueSoon = isDateOnlyWithinDays(
|
||||
dueDate,
|
||||
DAYS_THRESHOLD,
|
||||
today,
|
||||
);
|
||||
const isOldPeriod = boleto.period < currentPeriod;
|
||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||
const isNextPeriod = boleto.period === nextPeriod;
|
||||
const amount = toNumber(boleto.amount);
|
||||
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
||||
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
||||
|
||||
if (isOldPeriod) {
|
||||
notifications.push({
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: "overdue",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: true,
|
||||
notificationKey,
|
||||
fingerprint: "overdue",
|
||||
href,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
|
||||
|
||||
notifications.push({
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: notificationStatus,
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: boletoIsOverdue,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
} else if (isNextPeriod && boletoIsDueSoon) {
|
||||
notifications.push({
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: "due_soon",
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: false,
|
||||
notificationKey,
|
||||
fingerprint: "due_soon",
|
||||
href,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar: atrasados primeiro, depois por data de vencimento
|
||||
notifications.sort((a, b) => {
|
||||
if (a.status === "overdue" && b.status !== "overdue") return -1;
|
||||
if (a.status !== "overdue" && b.status === "overdue") return 1;
|
||||
return a.dueDate.localeCompare(b.dueDate);
|
||||
});
|
||||
|
||||
// Orçamentos excedidos e críticos
|
||||
const budgetNotifications: BudgetNotification[] = [];
|
||||
|
||||
for (const row of budgetRows) {
|
||||
const budgetAmount = toNumber(row.budgetAmount);
|
||||
const spentAmount = toNumber(row.spentAmount);
|
||||
if (budgetAmount <= 0) continue;
|
||||
|
||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
||||
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
|
||||
const notificationKey = buildBudgetNotificationKey(
|
||||
row.categoryId,
|
||||
row.orcamentoId,
|
||||
row.period,
|
||||
);
|
||||
|
||||
budgetNotifications.push({
|
||||
categoryName: row.categoriaName,
|
||||
budgetAmount,
|
||||
spentAmount,
|
||||
usedPercentage,
|
||||
status: notificationStatus,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Excedidos primeiro, depois por percentual decrescente
|
||||
budgetNotifications.sort((a, b) => {
|
||||
if (a.status === "exceeded" && b.status !== "exceeded") return -1;
|
||||
if (a.status !== "exceeded" && b.status === "exceeded") return 1;
|
||||
return b.usedPercentage - a.usedPercentage;
|
||||
});
|
||||
|
||||
const notificationKeys = [
|
||||
...notifications.map((notification) => notification.notificationKey),
|
||||
...budgetNotifications.map((notification) => notification.notificationKey),
|
||||
];
|
||||
|
||||
let persistedStates: PersistedNotificationState[] = [];
|
||||
|
||||
if (notificationKeys.length > 0) {
|
||||
try {
|
||||
persistedStates = await db
|
||||
.select({
|
||||
notificationKey: dashboardNotificationStates.notificationKey,
|
||||
fingerprint: dashboardNotificationStates.fingerprint,
|
||||
readAt: dashboardNotificationStates.readAt,
|
||||
archivedAt: dashboardNotificationStates.archivedAt,
|
||||
})
|
||||
.from(dashboardNotificationStates)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, userId),
|
||||
inArray(
|
||||
dashboardNotificationStates.notificationKey,
|
||||
notificationKeys,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
console.warn(
|
||||
"[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateByKey = new Map(
|
||||
persistedStates.map((state) => [state.notificationKey, state]),
|
||||
);
|
||||
|
||||
const mergedNotifications = mergeNotificationState(notifications, stateByKey);
|
||||
const mergedBudgetNotifications = mergeNotificationState(
|
||||
budgetNotifications,
|
||||
stateByKey,
|
||||
);
|
||||
const visibleNotifications = mergedNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const visibleBudgetNotifications = mergedBudgetNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const unreadCount = [
|
||||
...visibleNotifications,
|
||||
...visibleBudgetNotifications,
|
||||
].filter((notification) => !notification.isRead).length;
|
||||
|
||||
return {
|
||||
notifications: mergedNotifications,
|
||||
budgetNotifications: mergedBudgetNotifications,
|
||||
unreadCount,
|
||||
visibleCount:
|
||||
visibleNotifications.length + visibleBudgetNotifications.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user