chore: sync staging
This commit is contained in:
parent
c5ddd54a3e
commit
561b19cf66
610 changed files with 105285 additions and 1206 deletions
346
src/server/report-exporters.ts
Normal file
346
src/server/report-exporters.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"use server"
|
||||
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { buildXlsxWorkbook } from "@/lib/xlsx"
|
||||
import { REPORT_EXPORT_DEFINITIONS, type ReportExportKey } from "@/lib/report-definitions"
|
||||
|
||||
type ViewerIdentity = {
|
||||
tenantId: string
|
||||
name: string
|
||||
email: string
|
||||
avatarUrl?: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export type ConvexReportContext = {
|
||||
client: ConvexHttpClient
|
||||
tenantId: string
|
||||
viewerId: Id<"users">
|
||||
}
|
||||
|
||||
export async function createConvexContext(identity: ViewerIdentity): Promise<ConvexReportContext> {
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
throw new Error("Convex URL não configurada para exportações")
|
||||
}
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const ensuredUser = await client.mutation(api.users.ensureUser, {
|
||||
tenantId: identity.tenantId,
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
avatarUrl: identity.avatarUrl ?? undefined,
|
||||
role: identity.role,
|
||||
})
|
||||
if (!ensuredUser?._id) {
|
||||
throw new Error("Não foi possível sincronizar usuário com o Convex")
|
||||
}
|
||||
return {
|
||||
client,
|
||||
tenantId: identity.tenantId,
|
||||
viewerId: ensuredUser._id as Id<"users">,
|
||||
}
|
||||
}
|
||||
|
||||
export type ReportArtifact = {
|
||||
fileName: string
|
||||
mimeType: string
|
||||
buffer: Uint8Array
|
||||
}
|
||||
|
||||
type BaseOptions = {
|
||||
range?: string
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
export async function buildHoursWorkbook(
|
||||
ctx: ConvexReportContext,
|
||||
options: BaseOptions & { search?: string }
|
||||
): Promise<ReportArtifact> {
|
||||
const report = await ctx.client.query(api.reports.hoursByClient, {
|
||||
tenantId: ctx.tenantId,
|
||||
viewerId: ctx.viewerId,
|
||||
range: options.range,
|
||||
})
|
||||
|
||||
type Item = {
|
||||
companyId: string
|
||||
name: string
|
||||
isAvulso: boolean
|
||||
internalMs: number
|
||||
externalMs: number
|
||||
totalMs: number
|
||||
contractedHoursPerMonth: number | null
|
||||
}
|
||||
|
||||
let items = (report.items as Item[]) ?? []
|
||||
if (options.companyId) {
|
||||
items = items.filter((item) => String(item.companyId) === options.companyId)
|
||||
}
|
||||
if (options.search) {
|
||||
const term = options.search.toLowerCase()
|
||||
items = items.filter((item) => item.name.toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
const summaryRows: Array<Array<unknown>> = [
|
||||
["Relatório", "Horas por cliente"],
|
||||
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||
]
|
||||
if (options.search) summaryRows.push(["Filtro", options.search])
|
||||
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||
summaryRows.push(["Total de clientes", items.length])
|
||||
|
||||
const dataRows = items.map((item) => {
|
||||
const internalHours = item.internalMs / 3_600_000
|
||||
const externalHours = item.externalMs / 3_600_000
|
||||
const totalHours = item.totalMs / 3_600_000
|
||||
const contracted = item.contractedHoursPerMonth
|
||||
const usagePct = contracted ? (totalHours / contracted) * 100 : null
|
||||
return [
|
||||
item.name,
|
||||
item.isAvulso ? "Sim" : "Não",
|
||||
Number(internalHours.toFixed(2)),
|
||||
Number(externalHours.toFixed(2)),
|
||||
Number(totalHours.toFixed(2)),
|
||||
contracted ?? null,
|
||||
usagePct !== null ? Number(usagePct.toFixed(1)) : null,
|
||||
]
|
||||
})
|
||||
|
||||
const workbook = buildXlsxWorkbook([
|
||||
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||
{
|
||||
name: "Clientes",
|
||||
headers: ["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"],
|
||||
rows: dataRows.length > 0 ? dataRows : [["—", "—", 0, 0, 0, null, null]],
|
||||
},
|
||||
])
|
||||
|
||||
const fileName = `hours-by-client-${ctx.tenantId}-${report.rangeDays ?? "90"}d${
|
||||
options.companyId ? `-${options.companyId}` : ""
|
||||
}${options.search ? `-${encodeURIComponent(options.search)}` : ""}.xlsx`
|
||||
|
||||
return {
|
||||
fileName,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: new Uint8Array(workbook),
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBacklogWorkbook(
|
||||
ctx: ConvexReportContext,
|
||||
options: BaseOptions
|
||||
): Promise<ReportArtifact> {
|
||||
const report = await ctx.client.query(api.reports.backlogOverview, {
|
||||
tenantId: ctx.tenantId,
|
||||
viewerId: ctx.viewerId,
|
||||
range: options.range,
|
||||
companyId: options.companyId as Id<"companies"> | undefined,
|
||||
})
|
||||
|
||||
const summaryRows: Array<Array<unknown>> = [
|
||||
["Relatório", "Backlog"],
|
||||
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||
]
|
||||
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||
summaryRows.push(["Chamados em aberto", report.totalOpen])
|
||||
|
||||
const STATUS_PT: Record<string, string> = {
|
||||
PENDING: "Pendentes",
|
||||
AWAITING_ATTENDANCE: "Em andamento",
|
||||
PAUSED: "Pausados",
|
||||
RESOLVED: "Resolvidos",
|
||||
}
|
||||
const PRIORITY_PT: Record<string, string> = {
|
||||
LOW: "Baixa",
|
||||
MEDIUM: "Média",
|
||||
HIGH: "Alta",
|
||||
URGENT: "Crítica",
|
||||
}
|
||||
|
||||
const distributionRows: Array<Array<unknown>> = []
|
||||
for (const [status, total] of Object.entries(report.statusCounts ?? {})) {
|
||||
distributionRows.push(["Status", STATUS_PT[status] ?? status, total])
|
||||
}
|
||||
for (const [priority, total] of Object.entries(report.priorityCounts ?? {})) {
|
||||
distributionRows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total])
|
||||
}
|
||||
for (const queue of report.queueCounts ?? []) {
|
||||
distributionRows.push(["Fila", queue.name || queue.id, queue.total])
|
||||
}
|
||||
|
||||
const workbook = buildXlsxWorkbook([
|
||||
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||
{
|
||||
name: "Distribuições",
|
||||
headers: ["Categoria", "Chave", "Total"],
|
||||
rows: distributionRows.length > 0 ? distributionRows : [["—", "—", 0]],
|
||||
},
|
||||
])
|
||||
|
||||
const fileName = `backlog-${ctx.tenantId}-${report.rangeDays ?? "90"}d${
|
||||
options.companyId ? `-${options.companyId}` : ""
|
||||
}.xlsx`
|
||||
|
||||
return {
|
||||
fileName,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: new Uint8Array(workbook),
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSlaWorkbook(
|
||||
ctx: ConvexReportContext,
|
||||
options: BaseOptions
|
||||
): Promise<ReportArtifact> {
|
||||
const report = await ctx.client.query(api.reports.slaOverview, {
|
||||
tenantId: ctx.tenantId,
|
||||
viewerId: ctx.viewerId,
|
||||
range: options.range,
|
||||
companyId: options.companyId as Id<"companies"> | undefined,
|
||||
})
|
||||
|
||||
const summaryRows: Array<Array<unknown>> = [
|
||||
["Relatório", "Produtividade"],
|
||||
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||
]
|
||||
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||
summaryRows.push(["Tickets totais", report.totals.total])
|
||||
summaryRows.push(["Tickets abertos", report.totals.open])
|
||||
summaryRows.push(["Tickets resolvidos", report.totals.resolved])
|
||||
summaryRows.push(["Atrasados (SLA)", report.totals.overdue])
|
||||
summaryRows.push(["Tempo médio 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"])
|
||||
summaryRows.push(["Respostas registradas", report.response.responsesRegistered ?? 0])
|
||||
summaryRows.push(["Tempo médio resolução (min)", report.resolution.averageResolutionMinutes ?? "—"])
|
||||
summaryRows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0])
|
||||
|
||||
const queueRows = (report.queueBreakdown ?? []).map((queue) => [queue.name || queue.id, queue.open])
|
||||
|
||||
const workbook = buildXlsxWorkbook([
|
||||
{ name: "Resumo", headers: ["Indicador", "Valor"], rows: summaryRows },
|
||||
{
|
||||
name: "Filas",
|
||||
headers: ["Fila", "Chamados abertos"],
|
||||
rows: queueRows.length > 0 ? queueRows : [["—", 0]],
|
||||
},
|
||||
])
|
||||
|
||||
const fileName = `sla-${ctx.tenantId}-${report.rangeDays ?? "90"}d${
|
||||
options.companyId ? `-${options.companyId}` : ""
|
||||
}.xlsx`
|
||||
|
||||
return {
|
||||
fileName,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: new Uint8Array(workbook),
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCsatWorkbook(
|
||||
ctx: ConvexReportContext,
|
||||
options: BaseOptions
|
||||
): Promise<ReportArtifact> {
|
||||
const report = await ctx.client.query(api.reports.csatOverview, {
|
||||
tenantId: ctx.tenantId,
|
||||
viewerId: ctx.viewerId,
|
||||
range: options.range,
|
||||
companyId: options.companyId as Id<"companies"> | undefined,
|
||||
})
|
||||
|
||||
const summaryRows: Array<Array<unknown>> = [
|
||||
["Relatório", "CSAT"],
|
||||
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||
]
|
||||
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||
summaryRows.push(["CSAT médio", report.averageScore ?? "—"])
|
||||
summaryRows.push(["Total de respostas", report.totalSurveys ?? 0])
|
||||
|
||||
const distributionRows = (report.distribution ?? []).map((entry) => [entry.score, entry.total])
|
||||
const recentRows = (report.recent ?? []).map((item) => [
|
||||
`#${item.reference}`,
|
||||
item.score,
|
||||
new Date(item.receivedAt).toISOString(),
|
||||
])
|
||||
|
||||
const workbook = buildXlsxWorkbook([
|
||||
{ name: "Resumo", headers: ["Métrica", "Valor"], rows: summaryRows },
|
||||
{
|
||||
name: "Distribuição",
|
||||
headers: ["Nota", "Total"],
|
||||
rows: distributionRows.length > 0 ? distributionRows : [["—", 0]],
|
||||
},
|
||||
{
|
||||
name: "Respostas recentes",
|
||||
headers: ["Ticket", "Nota", "Recebido em"],
|
||||
rows: recentRows.length > 0 ? recentRows : [["—", "—", "—"]],
|
||||
},
|
||||
])
|
||||
|
||||
const fileName = `csat-${ctx.tenantId}-${report.rangeDays ?? "90"}d${
|
||||
options.companyId ? `-${options.companyId}` : ""
|
||||
}.xlsx`
|
||||
|
||||
return {
|
||||
fileName,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: new Uint8Array(workbook),
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildTicketsByChannelWorkbook(
|
||||
ctx: ConvexReportContext,
|
||||
options: BaseOptions
|
||||
): Promise<ReportArtifact> {
|
||||
const report = await ctx.client.query(api.reports.ticketsByChannel, {
|
||||
tenantId: ctx.tenantId,
|
||||
viewerId: ctx.viewerId,
|
||||
range: options.range,
|
||||
companyId: options.companyId as Id<"companies"> | undefined,
|
||||
})
|
||||
|
||||
const CHANNEL_PT: Record<string, string> = {
|
||||
EMAIL: "E-mail",
|
||||
PHONE: "Telefone",
|
||||
CHAT: "Chat",
|
||||
WHATSAPP: "WhatsApp",
|
||||
API: "API",
|
||||
MANUAL: "Manual",
|
||||
WEB: "Portal",
|
||||
PORTAL: "Portal",
|
||||
}
|
||||
|
||||
const summaryRows: Array<Array<unknown>> = [
|
||||
["Relatório", "Tickets por canal"],
|
||||
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||
]
|
||||
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||
summaryRows.push(["Total de linhas", report.points.length])
|
||||
|
||||
const header = ["Data", ...(report.channels ?? []).map((ch) => CHANNEL_PT[ch] ?? ch)]
|
||||
const dataRows = (report.points ?? []).map((point) => [
|
||||
point.date,
|
||||
...(report.channels ?? []).map((ch) => point.values[ch] ?? 0),
|
||||
])
|
||||
|
||||
const workbook = buildXlsxWorkbook([
|
||||
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||
{
|
||||
name: "Distribuição",
|
||||
headers: header,
|
||||
rows: dataRows.length > 0 ? dataRows : [[new Date().toISOString().slice(0, 10), ...(report.channels ?? []).map(() => 0)]],
|
||||
},
|
||||
])
|
||||
|
||||
const fileName = `tickets-by-channel-${ctx.tenantId}-${options.range ?? "90d"}${
|
||||
options.companyId ? `-${options.companyId}` : ""
|
||||
}.xlsx`
|
||||
|
||||
return {
|
||||
fileName,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: new Uint8Array(workbook),
|
||||
}
|
||||
}
|
||||
245
src/server/report-schedule-runner.ts
Normal file
245
src/server/report-schedule-runner.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use server"
|
||||
|
||||
import type { Prisma } from "@prisma/client"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { env } from "@/lib/env"
|
||||
import { sendSmtpMail } from "@/server/email-smtp"
|
||||
import {
|
||||
buildBacklogWorkbook,
|
||||
buildCsatWorkbook,
|
||||
buildHoursWorkbook,
|
||||
buildSlaWorkbook,
|
||||
buildTicketsByChannelWorkbook,
|
||||
createConvexContext,
|
||||
type ConvexReportContext,
|
||||
type ReportArtifact,
|
||||
type ReportExportKey,
|
||||
} from "@/server/report-exporters"
|
||||
import {
|
||||
computeNextRunAt,
|
||||
sanitizeRecipients,
|
||||
sanitizeReportKeys,
|
||||
} from "@/server/report-schedule-service"
|
||||
|
||||
const GENERATORS: Record<
|
||||
ReportExportKey,
|
||||
(
|
||||
ctx: ConvexReportContext,
|
||||
options: { range?: string; companyId?: string | null }
|
||||
) => Promise<ReportArtifact>
|
||||
> = {
|
||||
hours: buildHoursWorkbook,
|
||||
backlog: buildBacklogWorkbook,
|
||||
sla: buildSlaWorkbook,
|
||||
csat: buildCsatWorkbook,
|
||||
"tickets-by-channel": buildTicketsByChannelWorkbook,
|
||||
}
|
||||
|
||||
type ScheduleRunnerIdentity = {
|
||||
userId?: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
avatarUrl?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export type RunReportSchedulesOptions = {
|
||||
tenantId?: string
|
||||
scheduleId?: string
|
||||
initiatedBy?: ScheduleRunnerIdentity
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export type RunReportSchedulesResult = {
|
||||
processed: number
|
||||
results: Array<{ id: string; status: "completed" | "failed" | "skipped"; error?: string }>
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function runReportSchedules(
|
||||
options: RunReportSchedulesOptions = {}
|
||||
): Promise<RunReportSchedulesResult> {
|
||||
const now = options.now ?? new Date()
|
||||
|
||||
const where: Prisma.ReportExportScheduleWhereInput = {
|
||||
status: "ACTIVE",
|
||||
}
|
||||
if (options.tenantId) {
|
||||
where.tenantId = options.tenantId
|
||||
}
|
||||
if (options.scheduleId) {
|
||||
where.id = options.scheduleId
|
||||
} else {
|
||||
where.nextRunAt = { lte: now }
|
||||
}
|
||||
|
||||
const schedules = await prisma.reportExportSchedule.findMany({
|
||||
where,
|
||||
orderBy: { nextRunAt: "asc" },
|
||||
})
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
results: [],
|
||||
message: options.scheduleId
|
||||
? "Agendamento não encontrado ou inativo."
|
||||
: "Nenhum agendamento pendente.",
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackEmail =
|
||||
options.initiatedBy?.email ??
|
||||
env.SMTP?.from ??
|
||||
process.env.REPORTS_CRON_FALLBACK_EMAIL ??
|
||||
"scheduler@sistema.local"
|
||||
|
||||
const results: RunReportSchedulesResult["results"] = []
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const reportKeys = sanitizeReportKeys((schedule.reportKeys as string[] | null) ?? [])
|
||||
if (!reportKeys.length) {
|
||||
results.push({ id: schedule.id, status: "skipped", error: "Nenhum relatório configurado." })
|
||||
continue
|
||||
}
|
||||
|
||||
const owner =
|
||||
(await prisma.user.findUnique({
|
||||
where: { id: schedule.createdBy },
|
||||
select: { name: true, email: true, avatarUrl: true, role: true },
|
||||
})) ?? null
|
||||
|
||||
let context: ConvexReportContext | null = null
|
||||
try {
|
||||
context = await createConvexContext({
|
||||
tenantId: schedule.tenantId,
|
||||
name: owner?.name ?? options.initiatedBy?.name ?? options.initiatedBy?.email ?? fallbackEmail,
|
||||
email: owner?.email ?? fallbackEmail,
|
||||
avatarUrl: owner?.avatarUrl ?? options.initiatedBy?.avatarUrl ?? undefined,
|
||||
role: (owner?.role ?? options.initiatedBy?.role ?? "ADMIN").toUpperCase(),
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id: schedule.id,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Falha ao inicializar contexto Convex",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const run = await prisma.reportExportRun.create({
|
||||
data: {
|
||||
tenantId: schedule.tenantId,
|
||||
scheduleId: schedule.id,
|
||||
status: "RUNNING",
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const artifacts: Array<{ key: ReportExportKey; fileName: string; mimeType: string; data: string }> = []
|
||||
for (const key of reportKeys) {
|
||||
const generator = GENERATORS[key]
|
||||
if (!generator) continue
|
||||
const artifact = await generator(context, {
|
||||
range: schedule.range,
|
||||
companyId: schedule.companyId ?? undefined,
|
||||
})
|
||||
artifacts.push({
|
||||
key,
|
||||
fileName: artifact.fileName,
|
||||
mimeType: artifact.mimeType,
|
||||
data: Buffer.from(artifact.buffer).toString("base64"),
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.reportExportRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
artifacts,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.reportExportSchedule.update({
|
||||
where: { id: schedule.id },
|
||||
data: {
|
||||
lastRunAt: new Date(),
|
||||
nextRunAt: computeNextRunAt(
|
||||
{
|
||||
frequency: schedule.frequency as "daily" | "weekly" | "monthly",
|
||||
dayOfWeek: schedule.dayOfWeek,
|
||||
dayOfMonth: schedule.dayOfMonth,
|
||||
hour: schedule.hour,
|
||||
minute: schedule.minute,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
now
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const recipients = sanitizeRecipients((schedule.recipients as string[] | null) ?? [])
|
||||
if (artifacts.length && recipients.length && env.SMTP) {
|
||||
await notifyRecipients(run.id, schedule.name, artifacts, recipients)
|
||||
}
|
||||
|
||||
results.push({ id: schedule.id, status: "completed" })
|
||||
} catch (error) {
|
||||
await prisma.reportExportRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
completedAt: new Date(),
|
||||
error: error instanceof Error ? error.message : "Erro inesperado",
|
||||
},
|
||||
})
|
||||
results.push({
|
||||
id: schedule.id,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Erro inesperado",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processed: results.length,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyRecipients(
|
||||
runId: string,
|
||||
scheduleName: string,
|
||||
artifacts: Array<{ key: string; fileName: string; mimeType: string; data: string }>,
|
||||
recipients: string[]
|
||||
) {
|
||||
if (!env.SMTP) return
|
||||
const baseUrl =
|
||||
env.REPORTS_CRON_BASE_URL ?? env.NEXT_PUBLIC_APP_URL ?? env.BETTER_AUTH_URL ?? "http://localhost:3000"
|
||||
|
||||
const links = artifacts
|
||||
.map(
|
||||
(artifact, index) =>
|
||||
`${baseUrl.replace(/\/$/, "")}/api/reports/schedules/runs/${runId}?artifact=${index}`
|
||||
)
|
||||
.map(
|
||||
(href, index) =>
|
||||
`<li><a href="${href}">${artifacts[index].fileName}</a> (${artifacts[index].mimeType})</li>`
|
||||
)
|
||||
.join("")
|
||||
|
||||
const html = `
|
||||
<p>Os relatórios agendados <strong>${scheduleName}</strong> foram gerados.</p>
|
||||
<p>Arquivos disponíveis:</p>
|
||||
<ul>${links}</ul>
|
||||
<p>Este e-mail foi enviado automaticamente.</p>
|
||||
`
|
||||
|
||||
try {
|
||||
await sendSmtpMail(env.SMTP, recipients, `Relatórios agendados - ${scheduleName}`, html)
|
||||
} catch (error) {
|
||||
console.error("Falha ao enviar e-mail do agendamento", error)
|
||||
}
|
||||
}
|
||||
149
src/server/report-schedule-service.ts
Normal file
149
src/server/report-schedule-service.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"use server"
|
||||
|
||||
import { addMonths, addWeeks, isBefore } from "date-fns"
|
||||
import type { ReportExportRun, ReportExportSchedule } from "@prisma/client"
|
||||
import { REPORT_EXPORT_DEFINITIONS, type ReportExportKey } from "@/lib/report-definitions"
|
||||
|
||||
type SerializableSchedule = ReportExportSchedule & {
|
||||
reportKeys: ReportExportKey[] | null
|
||||
recipients: string[] | null
|
||||
}
|
||||
|
||||
export function sanitizeReportKeys(keys: string[]): ReportExportKey[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
keys
|
||||
.map((key) => key as ReportExportKey)
|
||||
.filter((key): key is ReportExportKey => Boolean(REPORT_EXPORT_DEFINITIONS[key]))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeRecipients(recipients: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
recipients
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type NextRunInput = {
|
||||
frequency: "daily" | "weekly" | "monthly"
|
||||
dayOfWeek?: number | null
|
||||
dayOfMonth?: number | null
|
||||
hour: number
|
||||
minute: number
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export function computeNextRunAt({ frequency, dayOfWeek, dayOfMonth, hour, minute, timezone }: NextRunInput, currentDate = new Date()): Date {
|
||||
const base = zonedDate(currentDate, timezone)
|
||||
let candidate = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate(), hour, minute, 0, 0))
|
||||
|
||||
if (frequency === "daily") {
|
||||
if (!isBefore(base, candidate)) {
|
||||
candidate.setUTCDate(candidate.getUTCDate() + 1)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
if (frequency === "weekly") {
|
||||
const targetDow = typeof dayOfWeek === "number" ? clamp(dayOfWeek, 0, 6) : 1
|
||||
while (candidate.getUTCDay() !== targetDow || !isBefore(base, candidate)) {
|
||||
candidate = addWeeks(candidate, 1)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// monthly
|
||||
const targetDay = typeof dayOfMonth === "number" ? clamp(dayOfMonth, 1, 28) : 1
|
||||
candidate.setUTCDate(targetDay)
|
||||
if (!isBefore(base, candidate)) {
|
||||
candidate = addMonths(candidate, 1)
|
||||
candidate.setUTCDate(Math.min(targetDay, daysInMonth(candidate)))
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function daysInMonth(date: Date) {
|
||||
const year = date.getUTCFullYear()
|
||||
const month = date.getUTCMonth()
|
||||
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function zonedDate(date: Date, timeZone: string) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
const parts = formatter.formatToParts(date)
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value ?? "0")
|
||||
const year = get("year")
|
||||
const month = get("month")
|
||||
const day = get("day")
|
||||
const hour = get("hour")
|
||||
const minute = get("minute")
|
||||
const second = get("second")
|
||||
return new Date(Date.UTC(year, month - 1, day, hour, minute, second))
|
||||
}
|
||||
|
||||
export function serializeSchedule(
|
||||
schedule: SerializableSchedule,
|
||||
runs: ReportExportRun[] = []
|
||||
) {
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
tenantId: schedule.tenantId,
|
||||
reportKeys: schedule.reportKeys ?? [],
|
||||
range: schedule.range,
|
||||
companyId: schedule.companyId,
|
||||
companyName: schedule.companyName,
|
||||
format: schedule.format,
|
||||
frequency: schedule.frequency,
|
||||
dayOfWeek: schedule.dayOfWeek,
|
||||
dayOfMonth: schedule.dayOfMonth,
|
||||
hour: schedule.hour,
|
||||
minute: schedule.minute,
|
||||
timezone: schedule.timezone,
|
||||
recipients: schedule.recipients ?? [],
|
||||
status: schedule.status,
|
||||
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
|
||||
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
|
||||
createdAt: schedule.createdAt.toISOString(),
|
||||
updatedAt: schedule.updatedAt.toISOString(),
|
||||
runs: runs.map((run) => ({
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt.toISOString(),
|
||||
completedAt: run.completedAt?.toISOString() ?? null,
|
||||
error: run.error,
|
||||
artifacts: Array.isArray(run.artifacts)
|
||||
? (run.artifacts as Array<Record<string, unknown>>).map((artifact, index) => {
|
||||
const rawKey = artifact["key"]
|
||||
const rawName = artifact["fileName"]
|
||||
const rawMime = artifact["mimeType"]
|
||||
return {
|
||||
index,
|
||||
key: typeof rawKey === "string" ? rawKey : `arquivo-${index + 1}`,
|
||||
fileName: typeof rawName === "string" ? rawName : null,
|
||||
mimeType: typeof rawMime === "string" ? rawMime : null,
|
||||
}
|
||||
})
|
||||
: [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue