feat: adição de novos ícones SVG e configuração do ambiente
- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter - Implementados ícones para modos claro e escuro do ChatGPT - Criado script de inicialização para PostgreSQL com extensão pgcrypto - Adicionado script de configuração de ambiente que faz backup do .env - Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
114
lib/pagadores/access.ts
Normal file
114
lib/pagadores/access.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
pagadores,
|
||||
pagadorShares,
|
||||
user as usersTable,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
|
||||
canEdit: boolean;
|
||||
sharedByName: string | null;
|
||||
sharedByEmail: string | null;
|
||||
shareId: string | null;
|
||||
};
|
||||
|
||||
export async function fetchPagadoresWithAccess(
|
||||
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 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,
|
||||
}));
|
||||
|
||||
return [...ownedMapped, ...sharedMapped];
|
||||
}
|
||||
|
||||
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.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)
|
||||
),
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
return Boolean(pagadorRow);
|
||||
}
|
||||
|
||||
export async function userHasPagadorAccess(
|
||||
userId: string,
|
||||
pagadorId: string
|
||||
) {
|
||||
const access = await getPagadorAccess(userId, pagadorId);
|
||||
return Boolean(access);
|
||||
}
|
||||
13
lib/pagadores/constants.ts
Normal file
13
lib/pagadores/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Pagador constants
|
||||
*
|
||||
* Extracted from /lib/pagadores.ts
|
||||
*/
|
||||
|
||||
export const PAGADOR_STATUS_OPTIONS = ["Ativo", "Inativo"] as const;
|
||||
|
||||
export type PagadorStatus = (typeof PAGADOR_STATUS_OPTIONS)[number];
|
||||
|
||||
export const PAGADOR_ROLE_ADMIN = "admin";
|
||||
export const PAGADOR_ROLE_TERCEIRO = "terceiro";
|
||||
export const DEFAULT_PAGADOR_AVATAR = "avatar_010.svg";
|
||||
60
lib/pagadores/defaults.ts
Normal file
60
lib/pagadores/defaults.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pagador defaults - User seeding logic
|
||||
*
|
||||
* Moved from /lib/pagador-defaults.ts to /lib/pagadores/defaults.ts
|
||||
*/
|
||||
|
||||
import { pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
|
||||
const userId = user.id;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAnyPagador = await db.query.pagadores.findFirst({
|
||||
columns: { id: true, role: true },
|
||||
where: eq(pagadores.userId, userId),
|
||||
});
|
||||
|
||||
if (hasAnyPagador) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
await db.insert(pagadores).values({
|
||||
name,
|
||||
email: user.email ?? null,
|
||||
status: DEFAULT_STATUS,
|
||||
role: PAGADOR_ROLE_ADMIN,
|
||||
avatarUrl,
|
||||
note: null,
|
||||
isAutoSend: false,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
325
lib/pagadores/details.ts
Normal file
325
lib/pagadores/details.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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,
|
||||
} from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const RECEITA = "Receita";
|
||||
const DESPESA = "Despesa";
|
||||
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>;
|
||||
};
|
||||
|
||||
export type PagadorHistoryPoint = {
|
||||
period: string;
|
||||
label: string;
|
||||
receitas: number;
|
||||
despesas: number;
|
||||
};
|
||||
|
||||
export type PagadorCardUsageItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type PagadorBoletoStats = {
|
||||
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;
|
||||
};
|
||||
|
||||
const formatPeriod = (year: number, month: number) =>
|
||||
`${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 buildPeriodWindow = (period: string, months: number) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const excludeAutoInvoiceEntries = () =>
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`))
|
||||
);
|
||||
|
||||
type BaseFilters = {
|
||||
userId: string;
|
||||
pagadorId: string;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export async function fetchPagadorMonthlyBreakdown({
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalExpenses,
|
||||
totalIncomes,
|
||||
paymentSplits,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPagadorHistory({
|
||||
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 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 }
|
||||
>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}: 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);
|
||||
|
||||
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,
|
||||
}: 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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalAmount: paidAmount + pendingAmount,
|
||||
paidAmount,
|
||||
pendingAmount,
|
||||
paidCount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
237
lib/pagadores/notifications.ts
Normal file
237
lib/pagadores/notifications.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type PagadorNotificationRequest = {
|
||||
userLabel: string;
|
||||
action: ActionType;
|
||||
entriesByPagador: Map<string, NotificationEntry[]>;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
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",
|
||||
});
|
||||
};
|
||||
|
||||
const buildHtmlBody = ({
|
||||
userLabel,
|
||||
action,
|
||||
entries,
|
||||
}: {
|
||||
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 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>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.name ?? "Sem descrição"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.paymentMethod ?? "—"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.condition ?? "—"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
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>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Data</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Descrição</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Pagamento</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Condição</th>
|
||||
<th style="text-align:right;padding:8px;border-bottom:1px solid #e2e8f0;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-size:12px;color:#94a3b8;">
|
||||
Enviado automaticamente por ${userLabel} via OpenSheets.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export async function sendPagadorAutoEmails({
|
||||
userLabel,
|
||||
action,
|
||||
entriesByPagador,
|
||||
}: PagadorNotificationRequest) {
|
||||
"use server";
|
||||
|
||||
if (entriesByPagador.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const pagadorIds = Array.from(entriesByPagador.keys());
|
||||
if (pagadorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pagadorRows = await db.query.pagadores.findMany({
|
||||
where: inArray(pagadores.id, pagadorIds),
|
||||
});
|
||||
|
||||
if (pagadorRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 entries = entriesByPagador.get(pagador.id);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = buildHtmlBody({
|
||||
userLabel,
|
||||
action,
|
||||
entries,
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const buildEntriesByPagador = (
|
||||
records: RawNotificationRecord[]
|
||||
): Map<string, NotificationEntry[]> => {
|
||||
const map = new Map<string, NotificationEntry[]>();
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
return map;
|
||||
};
|
||||
65
lib/pagadores/utils.ts
Normal file
65
lib/pagadores/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pagador utility functions
|
||||
*
|
||||
* Extracted from /lib/pagadores.ts
|
||||
*/
|
||||
|
||||
import { DEFAULT_PAGADOR_AVATAR } from "./constants";
|
||||
|
||||
/**
|
||||
* Normaliza o caminho do avatar extraindo apenas o nome do arquivo.
|
||||
* Remove qualquer caminho anterior e retorna null se não houver avatar.
|
||||
*/
|
||||
export const normalizeAvatarPath = (
|
||||
avatar: string | null | undefined
|
||||
): string | null => {
|
||||
if (!avatar) return null;
|
||||
const file = avatar.split("/").filter(Boolean).pop();
|
||||
return file ?? avatar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retorna o caminho completo para o avatar, com fallback para o avatar padrão.
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normaliza nome a partir de email
|
||||
*/
|
||||
export const normalizeNameFromEmail = (
|
||||
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(" ");
|
||||
};
|
||||
Reference in New Issue
Block a user