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,114 +1,95 @@
import {
pagadores,
pagadorShares,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { pagadores, pagadorShares, user as usersTable } from "@/db/schema";
import { db } from "@/lib/db";
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
};
export async function fetchPagadoresWithAccess(
userId: string
userId: string,
): Promise<PagadorWithAccess[]> {
const [owned, shared] = await Promise.all([
db.query.pagadores.findMany({
where: eq(pagadores.userId, userId),
}),
db
.select({
shareId: pagadorShares.id,
pagador: pagadores,
ownerName: usersTable.name,
ownerEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(
pagadores,
eq(pagadorShares.pagadorId, pagadores.id)
)
.leftJoin(
usersTable,
eq(pagadores.userId, usersTable.id)
)
.where(eq(pagadorShares.sharedWithUserId, userId)),
]);
const [owned, shared] = await Promise.all([
db.query.pagadores.findMany({
where: eq(pagadores.userId, userId),
}),
db
.select({
shareId: pagadorShares.id,
pagador: pagadores,
ownerName: usersTable.name,
ownerEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(pagadores, eq(pagadorShares.pagadorId, pagadores.id))
.leftJoin(usersTable, eq(pagadores.userId, usersTable.id))
.where(eq(pagadorShares.sharedWithUserId, userId)),
]);
const ownedMapped: PagadorWithAccess[] = owned.map((item) => ({
...item,
canEdit: true,
sharedByName: null,
sharedByEmail: null,
shareId: null,
}));
const ownedMapped: PagadorWithAccess[] = owned.map((item) => ({
...item,
canEdit: true,
sharedByName: null,
sharedByEmail: null,
shareId: null,
}));
const sharedMapped: PagadorWithAccess[] = shared.map((item) => ({
...item.pagador,
shareCode: null,
canEdit: false,
sharedByName: item.ownerName ?? null,
sharedByEmail: item.ownerEmail ?? null,
shareId: item.shareId,
}));
const sharedMapped: PagadorWithAccess[] = shared.map((item) => ({
...item.pagador,
shareCode: null,
canEdit: false,
sharedByName: item.ownerName ?? null,
sharedByEmail: item.ownerEmail ?? null,
shareId: item.shareId,
}));
return [...ownedMapped, ...sharedMapped];
return [...ownedMapped, ...sharedMapped];
}
export async function getPagadorAccess(
userId: string,
pagadorId: string
) {
const pagador = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId)),
});
export async function getPagadorAccess(userId: string, pagadorId: string) {
const pagador = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId)),
});
if (!pagador) {
return null;
}
if (!pagador) {
return null;
}
if (pagador.userId === userId) {
return {
pagador,
canEdit: true,
share: null as typeof pagadorShares.$inferSelect | null,
};
}
if (pagador.userId === userId) {
return {
pagador,
canEdit: true,
share: null as typeof pagadorShares.$inferSelect | null,
};
}
const share = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId)
),
});
const share = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId),
),
});
if (!share) {
return null;
}
if (!share) {
return null;
}
return { pagador, canEdit: false, share };
return { pagador, canEdit: false, share };
}
export async function userCanEditPagador(
userId: string,
pagadorId: string
) {
const pagadorRow = await db.query.pagadores.findFirst({
columns: { id: true },
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)),
});
export async function userCanEditPagador(userId: string, pagadorId: string) {
const pagadorRow = await db.query.pagadores.findFirst({
columns: { id: true },
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)),
});
return Boolean(pagadorRow);
return Boolean(pagadorRow);
}
export async function userHasPagadorAccess(
userId: string,
pagadorId: string
) {
const access = await getPagadorAccess(userId, pagadorId);
return Boolean(access);
export async function userHasPagadorAccess(userId: string, pagadorId: string) {
const access = await getPagadorAccess(userId, pagadorId);
return Boolean(access);
}

View File

@@ -4,57 +4,57 @@
* Moved from /lib/pagador-defaults.ts to /lib/pagadores/defaults.ts
*/
import { eq } from "drizzle-orm";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
} from "./constants";
import { normalizeNameFromEmail } from "./utils";
import { eq } from "drizzle-orm";
const DEFAULT_STATUS = PAGADOR_STATUS_OPTIONS[0];
interface SeedUserLike {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
const userId = user.id;
const userId = user.id;
if (!userId) {
return;
}
if (!userId) {
return;
}
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
if (hasAnyPagador) {
return;
}
if (hasAnyPagador) {
return;
}
const name =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Pagador principal";
const name =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Pagador principal";
// Usa a imagem do Google se disponível, senão usa o avatar padrão
const avatarUrl = user.image ?? DEFAULT_PAGADOR_AVATAR;
// Usa a imagem do Google se disponível, senão usa o avatar padrão
const avatarUrl = user.image ?? DEFAULT_PAGADOR_AVATAR;
await db.insert(pagadores).values({
name,
email: user.email ?? null,
status: DEFAULT_STATUS,
role: PAGADOR_ROLE_ADMIN,
avatarUrl,
note: null,
isAutoSend: false,
userId,
});
await db.insert(pagadores).values({
name,
email: user.email ?? null,
status: DEFAULT_STATUS,
role: PAGADOR_ROLE_ADMIN,
avatarUrl,
note: null,
isAutoSend: false,
userId,
});
}

View File

@@ -2,17 +2,17 @@ import { cartoes, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
and,
eq,
gte,
ilike,
isNull,
lte,
not,
or,
sum,
and,
eq,
gte,
ilike,
isNull,
lte,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { sql } from "drizzle-orm";
const RECEITA = "Receita";
const DESPESA = "Despesa";
@@ -20,306 +20,306 @@ const PAYMENT_METHOD_CARD = "Cartão de crédito";
const PAYMENT_METHOD_BOLETO = "Boleto";
export type PagadorMonthlyBreakdown = {
totalExpenses: number;
totalIncomes: number;
paymentSplits: Record<"card" | "boleto" | "instant", number>;
totalExpenses: number;
totalIncomes: number;
paymentSplits: Record<"card" | "boleto" | "instant", number>;
};
export type PagadorHistoryPoint = {
period: string;
label: string;
receitas: number;
despesas: number;
period: string;
label: string;
receitas: number;
despesas: number;
};
export type PagadorCardUsageItem = {
id: string;
name: string;
logo: string | null;
amount: number;
id: string;
name: string;
logo: string | null;
amount: number;
};
export type PagadorBoletoStats = {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
const toNumber = (value: string | number | bigint | null) => {
if (typeof value === "number") {
return value;
}
if (typeof value === "bigint") {
return Number(value);
}
if (!value) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
if (typeof value === "number") {
return value;
}
if (typeof value === "bigint") {
return Number(value);
}
if (!value) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
const formatPeriod = (year: number, month: number) =>
`${year}-${String(month).padStart(2, "0")}`;
`${year}-${String(month).padStart(2, "0")}`;
const normalizePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
};
const buildPeriodWindow = (period: string, months: number) => {
const { year, month } = normalizePeriod(period);
const items: string[] = [];
let currentYear = year;
let currentMonth = month;
const { year, month } = normalizePeriod(period);
const items: string[] = [];
let currentYear = year;
let currentMonth = month;
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
return items;
return items;
};
const formatPeriodLabel = (period: string) => {
try {
const { year, month } = normalizePeriod(period);
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "short",
});
const date = new Date(year, month - 1, 1);
const rawLabel = formatter.format(date).replace(".", "");
const label =
rawLabel.length > 0
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
: rawLabel;
const suffix = String(year).slice(-2);
return `${label}/${suffix}`;
} catch {
return period;
}
try {
const { year, month } = normalizePeriod(period);
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "short",
});
const date = new Date(year, month - 1, 1);
const rawLabel = formatter.format(date).replace(".", "");
const label =
rawLabel.length > 0
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
: rawLabel;
const suffix = String(year).slice(-2);
return `${label}/${suffix}`;
} catch {
return period;
}
};
const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`))
);
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
type BaseFilters = {
userId: string;
pagadorId: string;
period: string;
userId: string;
pagadorId: string;
period: string;
};
export async function fetchPagadorMonthlyBreakdown({
userId,
pagadorId,
period,
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorMonthlyBreakdown> {
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.paymentMethod, lancamentos.transactionType);
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.paymentMethod, lancamentos.transactionType);
const paymentSplits: PagadorMonthlyBreakdown["paymentSplits"] = {
card: 0,
boleto: 0,
instant: 0,
};
let totalExpenses = 0;
let totalIncomes = 0;
const paymentSplits: PagadorMonthlyBreakdown["paymentSplits"] = {
card: 0,
boleto: 0,
instant: 0,
};
let totalExpenses = 0;
let totalIncomes = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
totalExpenses += total;
if (row.paymentMethod === PAYMENT_METHOD_CARD) {
paymentSplits.card += total;
} else if (row.paymentMethod === PAYMENT_METHOD_BOLETO) {
paymentSplits.boleto += total;
} else {
paymentSplits.instant += total;
}
} else if (row.transactionType === RECEITA) {
totalIncomes += total;
}
}
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
totalExpenses += total;
if (row.paymentMethod === PAYMENT_METHOD_CARD) {
paymentSplits.card += total;
} else if (row.paymentMethod === PAYMENT_METHOD_BOLETO) {
paymentSplits.boleto += total;
} else {
paymentSplits.instant += total;
}
} else if (row.transactionType === RECEITA) {
totalIncomes += total;
}
}
return {
totalExpenses,
totalIncomes,
paymentSplits,
};
return {
totalExpenses,
totalIncomes,
paymentSplits,
};
}
export async function fetchPagadorHistory({
userId,
pagadorId,
period,
months = 6,
userId,
pagadorId,
period,
months = 6,
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
const window = buildPeriodWindow(period, months);
const start = window[0];
const end = window[window.length - 1];
const window = buildPeriodWindow(period, months);
const start = window[0];
const end = window[window.length - 1];
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
gte(lancamentos.period, start),
lte(lancamentos.period, end),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.period, lancamentos.transactionType);
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
gte(lancamentos.period, start),
lte(lancamentos.period, end),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
const totalsByPeriod = new Map<
string,
{ receitas: number; despesas: number }
>();
const totalsByPeriod = new Map<
string,
{ receitas: number; despesas: number }
>();
for (const key of window) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
for (const key of window) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
for (const row of rows) {
const key = row.period ?? undefined;
if (!key || !totalsByPeriod.has(key)) continue;
const bucket = totalsByPeriod.get(key);
if (!bucket) continue;
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
bucket.despesas += total;
} else if (row.transactionType === RECEITA) {
bucket.receitas += total;
}
}
for (const row of rows) {
const key = row.period ?? undefined;
if (!key || !totalsByPeriod.has(key)) continue;
const bucket = totalsByPeriod.get(key);
if (!bucket) continue;
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
bucket.despesas += total;
} else if (row.transactionType === RECEITA) {
bucket.receitas += total;
}
}
return window.map((key) => ({
period: key,
label: formatPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
}));
return window.map((key) => ({
period: key,
label: formatPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
}));
}
export async function fetchPagadorCardUsage({
userId,
pagadorId,
period,
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorCardUsageItem[]> {
const rows = await db
.select({
cartaoId: lancamentos.cartaoId,
cardName: cartoes.name,
cardLogo: cartoes.logo,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_CARD),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
const rows = await db
.select({
cartaoId: lancamentos.cartaoId,
cardName: cartoes.name,
cardLogo: cartoes.logo,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_CARD),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
return rows
.filter((row) => Boolean(row.cartaoId))
.map((row) => {
if (!row.cartaoId) {
throw new Error("cartaoId should not be null after filter");
}
return {
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
};
})
.sort((a, b) => b.amount - a.amount);
return rows
.filter((row) => Boolean(row.cartaoId))
.map((row) => {
if (!row.cartaoId) {
throw new Error("cartaoId should not be null after filter");
}
return {
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
};
})
.sort((a, b) => b.amount - a.amount);
}
export async function fetchPagadorBoletoStats({
userId,
pagadorId,
period,
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorBoletoStats> {
const rows = await db
.select({
isSettled: lancamentos.isSettled,
totalAmount: sum(lancamentos.amount).as("total"),
totalCount: sql<number>`count(${lancamentos.id})`.as("count"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.isSettled);
const rows = await db
.select({
isSettled: lancamentos.isSettled,
totalAmount: sum(lancamentos.amount).as("total"),
totalCount: sql<number>`count(${lancamentos.id})`.as("count"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.isSettled);
let paidAmount = 0;
let pendingAmount = 0;
let paidCount = 0;
let pendingCount = 0;
let paidAmount = 0;
let pendingAmount = 0;
let paidCount = 0;
let pendingCount = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
const count = toNumber(row.totalCount);
if (row.isSettled) {
paidAmount += total;
paidCount += count;
} else {
pendingAmount += total;
pendingCount += count;
}
}
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
const count = toNumber(row.totalCount);
if (row.isSettled) {
paidAmount += total;
paidCount += count;
} else {
pendingAmount += total;
pendingCount += count;
}
}
return {
totalAmount: paidAmount + pendingAmount,
paidAmount,
pendingAmount,
paidCount,
pendingCount,
};
return {
totalAmount: paidAmount + pendingAmount,
paidAmount,
pendingAmount,
paidCount,
pendingCount,
};
}

View File

@@ -1,85 +1,85 @@
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { inArray } from "drizzle-orm";
import { Resend } from "resend";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
type ActionType = "created" | "deleted";
export type NotificationEntry = {
pagadorId: string;
name: string | null;
amount: number;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
pagadorId: string;
name: string | null;
amount: number;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
};
export type PagadorNotificationRequest = {
userLabel: string;
action: ActionType;
entriesByPagador: Map<string, NotificationEntry[]>;
userLabel: string;
action: ActionType;
entriesByPagador: Map<string, NotificationEntry[]>;
};
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const formatDate = (value: Date | null) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const buildHtmlBody = ({
userLabel,
action,
entries,
userLabel,
action,
entries,
}: {
userLabel: string;
action: ActionType;
entries: NotificationEntry[];
userLabel: string;
action: ActionType;
entries: NotificationEntry[];
}) => {
const actionLabel =
action === "created" ? "Novo lançamento registrado" : "Lançamento removido";
const actionDescription =
action === "created"
? "Um novo lançamento foi registrado em seu nome."
: "Um lançamento anteriormente registrado em seu nome foi removido.";
const actionLabel =
action === "created" ? "Novo lançamento registrado" : "Lançamento removido";
const actionDescription =
action === "created"
? "Um novo lançamento foi registrado em seu nome."
: "Um lançamento anteriormente registrado em seu nome foi removido.";
const rows = entries
.map((entry) => {
const label =
entry.transactionType === "Despesa"
? formatCurrency(Math.abs(entry.amount)) + " (Despesa)"
: formatCurrency(Math.abs(entry.amount)) + " (Receita)";
return `<tr>
const rows = entries
.map((entry) => {
const label =
entry.transactionType === "Despesa"
? `${formatCurrency(Math.abs(entry.amount))} (Despesa)`
: `${formatCurrency(Math.abs(entry.amount))} (Receita)`;
return `<tr>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${formatDate(
entry.purchaseDate
)}</td>
entry.purchaseDate,
)}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.name ?? "Sem descrição"
}</td>
entry.name ?? "Sem descrição"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.paymentMethod ?? "—"
}</td>
entry.paymentMethod ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.condition ?? "—"
}</td>
entry.condition ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
</tr>`;
})
.join("");
})
.join("");
return `
return `
<div style="font-family:'Inter',Arial,sans-serif;color:#0f172a;line-height:1.5;">
<h2 style="margin:0 0 8px 0;font-size:20px;">${actionLabel}</h2>
<p style="margin:0 0 16px 0;color:#475569;">${actionDescription}</p>
@@ -107,131 +107,131 @@ const buildHtmlBody = ({
};
export async function sendPagadorAutoEmails({
userLabel,
action,
entriesByPagador,
userLabel,
action,
entriesByPagador,
}: PagadorNotificationRequest) {
"use server";
"use server";
if (entriesByPagador.size === 0) {
return;
}
if (entriesByPagador.size === 0) {
return;
}
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
if (!resendApiKey) {
console.warn(
"RESEND_API_KEY não configurada. Envio automático de lançamentos ignorado."
);
return;
}
if (!resendApiKey) {
console.warn(
"RESEND_API_KEY não configurada. Envio automático de lançamentos ignorado.",
);
return;
}
const pagadorIds = Array.from(entriesByPagador.keys());
if (pagadorIds.length === 0) {
return;
}
const pagadorIds = Array.from(entriesByPagador.keys());
if (pagadorIds.length === 0) {
return;
}
const pagadorRows = await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
const pagadorRows = await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
if (pagadorRows.length === 0) {
return;
}
if (pagadorRows.length === 0) {
return;
}
const resend = new Resend(resendApiKey);
const subjectPrefix =
action === "created" ? "Novo lançamento" : "Lançamento removido";
const resend = new Resend(resendApiKey);
const subjectPrefix =
action === "created" ? "Novo lançamento" : "Lançamento removido";
const results = await Promise.allSettled(
pagadorRows.map(async (pagador) => {
if (!pagador.email || !pagador.isAutoSend) {
return;
}
const results = await Promise.allSettled(
pagadorRows.map(async (pagador) => {
if (!pagador.email || !pagador.isAutoSend) {
return;
}
const entries = entriesByPagador.get(pagador.id);
if (!entries || entries.length === 0) {
return;
}
const entries = entriesByPagador.get(pagador.id);
if (!entries || entries.length === 0) {
return;
}
const html = buildHtmlBody({
userLabel,
action,
entries,
});
const html = buildHtmlBody({
userLabel,
action,
entries,
});
await resend.emails.send({
from: resendFrom,
to: pagador.email,
subject: `${subjectPrefix} - ${pagador.name}`,
html,
});
})
);
await resend.emails.send({
from: resendFrom,
to: pagador.email,
subject: `${subjectPrefix} - ${pagador.name}`,
html,
});
}),
);
// Log any failed email sends
results.forEach((result, index) => {
if (result.status === "rejected") {
const pagador = pagadorRows[index];
console.error(
`Failed to send email notification to ${pagador?.name} (${pagador?.email}):`,
result.reason
);
}
});
// Log any failed email sends
results.forEach((result, index) => {
if (result.status === "rejected") {
const pagador = pagadorRows[index];
console.error(
`Failed to send email notification to ${pagador?.name} (${pagador?.email}):`,
result.reason,
);
}
});
}
export type RawNotificationRecord = {
pagadorId: string | null;
name: string | null;
amount: string | number | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | string | null;
period: string | null;
note: string | null;
pagadorId: string | null;
name: string | null;
amount: string | number | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | string | null;
period: string | null;
note: string | null;
};
export const buildEntriesByPagador = (
records: RawNotificationRecord[]
records: RawNotificationRecord[],
): Map<string, NotificationEntry[]> => {
const map = new Map<string, NotificationEntry[]>();
const map = new Map<string, NotificationEntry[]>();
records.forEach((record) => {
if (!record.pagadorId) {
return;
}
records.forEach((record) => {
if (!record.pagadorId) {
return;
}
const amount =
typeof record.amount === "number"
? record.amount
: Number(record.amount ?? 0);
const purchaseDate =
record.purchaseDate instanceof Date
? record.purchaseDate
: record.purchaseDate
? new Date(record.purchaseDate)
: null;
const amount =
typeof record.amount === "number"
? record.amount
: Number(record.amount ?? 0);
const purchaseDate =
record.purchaseDate instanceof Date
? record.purchaseDate
: record.purchaseDate
? new Date(record.purchaseDate)
: null;
const entry: NotificationEntry = {
pagadorId: record.pagadorId,
name: record.name ?? null,
amount,
transactionType: record.transactionType ?? null,
paymentMethod: record.paymentMethod ?? null,
condition: record.condition ?? null,
purchaseDate,
period: record.period ?? null,
note: record.note ?? null,
};
const entry: NotificationEntry = {
pagadorId: record.pagadorId,
name: record.name ?? null,
amount,
transactionType: record.transactionType ?? null,
paymentMethod: record.paymentMethod ?? null,
condition: record.condition ?? null,
purchaseDate,
period: record.period ?? null,
note: record.note ?? null,
};
const list = map.get(record.pagadorId) ?? [];
list.push(entry);
map.set(record.pagadorId, list);
});
const list = map.get(record.pagadorId) ?? [];
list.push(entry);
map.set(record.pagadorId, list);
});
return map;
return map;
};

View File

@@ -11,11 +11,11 @@ import { DEFAULT_PAGADOR_AVATAR } from "./constants";
* Remove qualquer caminho anterior e retorna null se não houver avatar.
*/
export const normalizeAvatarPath = (
avatar: string | null | undefined
avatar: string | null | undefined,
): string | null => {
if (!avatar) return null;
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
if (!avatar) return null;
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
};
/**
@@ -23,43 +23,43 @@ export const normalizeAvatarPath = (
* Se o avatar for uma URL completa (http/https/data), retorna diretamente.
*/
export const getAvatarSrc = (avatar: string | null | undefined): string => {
if (!avatar) {
return `/avatares/${DEFAULT_PAGADOR_AVATAR}`;
}
if (!avatar) {
return `/avatares/${DEFAULT_PAGADOR_AVATAR}`;
}
// Se for uma URL completa (Google, etc), retorna diretamente
if (
avatar.startsWith("http://") ||
avatar.startsWith("https://") ||
avatar.startsWith("data:")
) {
return avatar;
}
// Se for uma URL completa (Google, etc), retorna diretamente
if (
avatar.startsWith("http://") ||
avatar.startsWith("https://") ||
avatar.startsWith("data:")
) {
return avatar;
}
// Se for um caminho local, normaliza e adiciona o prefixo
const normalized = normalizeAvatarPath(avatar);
return `/avatares/${normalized ?? DEFAULT_PAGADOR_AVATAR}`;
// Se for um caminho local, normaliza e adiciona o prefixo
const normalized = normalizeAvatarPath(avatar);
return `/avatares/${normalized ?? DEFAULT_PAGADOR_AVATAR}`;
};
/**
* Normaliza nome a partir de email
*/
export const normalizeNameFromEmail = (
email: string | null | undefined
email: string | null | undefined,
): string => {
if (!email) {
return "Novo pagador";
}
const [local] = email.split("@");
if (!local) {
return "Novo pagador";
}
return local
.split(".")
.map((segment) =>
segment.length > 0
? segment[0]?.toUpperCase().concat(segment.slice(1))
: segment
)
.join(" ");
if (!email) {
return "Novo pagador";
}
const [local] = email.split("@");
if (!local) {
return "Novo pagador";
}
return local
.split(".")
.map((segment) =>
segment.length > 0
? segment[0]?.toUpperCase().concat(segment.slice(1))
: segment,
)
.join(" ");
};