sistema-de-chamados/src/server/report-schedule-runner.ts
2025-11-19 15:42:21 -03:00

244 lines
6.8 KiB
TypeScript

"use server"
import type { Prisma as PrismaTypes } from "@/generated/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: PrismaTypes.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(new Uint8Array(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)
}
}