Hours by client: add search and CSV filtering; add alerts cron (BRT 08:00 guard) + alerts panel filters; admin companies shows last alert; PDF Inter font from public/fonts; fix Select empty value; type cleanups; tests for CSV/TZ; remove Knowledge Base nav

This commit is contained in:
Esdras Renan 2025-10-07 15:39:55 -03:00
parent 2cf399dcb1
commit 08cc8037d5
151 changed files with 1404 additions and 214 deletions

View file

@ -0,0 +1,127 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { assertAdminSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import { sendSmtpMail } from "@/server/email-smtp"
export const runtime = "nodejs"
function fmtHours(ms: number) {
return (ms / 3600000).toFixed(2)
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 })
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? "30d"
const threshold = Number(searchParams.get("threshold") ?? 90)
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? "tenant-atlas"
// Ensure user exists in Convex to obtain a typed viewerId
let viewerId: Id<"users"> | null = null
try {
const ensured = await client.mutation(api.users.ensureUser, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = (ensured?._id ?? null) as Id<"users"> | null
} catch (error) {
console.error("Failed to synchronize user with Convex for alerts", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
const report = await client.query(api.reports.hoursByClient, {
tenantId,
viewerId,
range,
})
type HoursByClientItem = {
companyId: Id<"companies">
name: string
internalMs: number
externalMs: number
totalMs: number
contractedHoursPerMonth: number | null
}
const items = (report.items ?? []) as HoursByClientItem[]
const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold)
for (const item of alerts) {
// Find managers of the company in Prisma
const managers = await prisma.user.findMany({
where: {
tenantId,
companyId: item.companyId,
role: "MANAGER",
},
select: { email: true, name: true },
})
if (managers.length === 0) continue
const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%`
const body = `
<p>Olá,</p>
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%</strong>.</p>
<ul>
<li>Horas internas: <strong>${fmtHours(item.internalMs)}</strong></li>
<li>Horas externas: <strong>${fmtHours(item.externalMs)}</strong></li>
<li>Total: <strong>${fmtHours(item.totalMs)}</strong></li>
<li>Contratadas/mês: <strong>${item.contractedHoursPerMonth}</strong></li>
</ul>
<p>Reveja a alocação da equipe e, se necessário, ajuste o atendimento.</p>
`
let delivered = 0
for (const m of managers) {
try {
await sendSmtpMail(
{
host: env.SMTP!.host,
port: env.SMTP!.port,
username: env.SMTP!.username,
password: env.SMTP!.password,
from: env.SMTP!.from!,
},
m.email,
subject,
body
)
delivered += 1
} catch (error) {
console.error("Failed to send alert to", m.email, error)
}
}
try {
await client.mutation(api.alerts.log, {
tenantId,
companyId: item.companyId,
companyName: item.name,
usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100),
threshold,
range,
recipients: managers.map((m) => m.email),
deliveredCount: delivered,
})
} catch (error) {
console.error("Failed to log alert in Convex", error)
}
}
return NextResponse.json({ sent: alerts.length })
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function GET(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
const client = new ConvexHttpClient(convexUrl)
const { searchParams } = new URL(request.url)
const slugsParam = searchParams.get("slugs")
if (!slugsParam) return NextResponse.json({ items: {} })
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
const tenantId = session.user.tenantId ?? "tenant-atlas"
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
for (const slug of slugs) {
try {
const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as
| { createdAt: number; usagePct: number; threshold: number }
| null
result[slug] = last
} catch {
result[slug] = null
}
}
return NextResponse.json({ items: result })
}

View file

@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { rowsToCsv } from "@/lib/csv"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
@ -62,7 +51,7 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as any,
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []

View file

@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { rowsToCsv } from "@/lib/csv"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
@ -34,6 +23,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const companyId = searchParams.get("companyId") ?? undefined
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -62,11 +52,13 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "CSAT"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
if (companyId) rows.push(["EmpresaId", companyId])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
rows.push(["CSAT médio", report.averageScore ?? "—"])
@ -96,4 +88,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 })
}
}

View file

@ -6,17 +6,9 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { rowsToCsv } from "@/lib/csv"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
function msToHours(ms: number) {
return (ms / 3600000).toFixed(2)
}
@ -29,6 +21,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const q = searchParams.get("q")?.toLowerCase().trim() ?? ""
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -58,9 +51,11 @@ export async function GET(request: Request) {
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Horas por cliente"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
if (q) rows.push(["Filtro", q])
rows.push([])
rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"])
for (const item of report.items) {
const items = q ? report.items.filter((i: any) => String(i.name).toLowerCase().includes(q)) : report.items
for (const item of items) {
const internalH = msToHours(item.internalMs)
const externalH = msToHours(item.externalMs)
const totalH = msToHours(item.totalMs)
@ -72,7 +67,7 @@ export async function GET(request: Request) {
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${q ? `-${encodeURIComponent(q)}` : ''}.csv"`,
"Cache-Control": "no-store",
},
})
@ -80,4 +75,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 })
}
}

View file

@ -34,6 +34,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const companyId = searchParams.get("companyId") ?? undefined
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -62,11 +63,13 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "SLA e produtividade"])
rows.push(["Período", range ?? "—"])
if (companyId) rows.push(["EmpresaId", companyId])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
@ -98,4 +101,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 })
}
}

View file

@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { rowsToCsv } from "@/lib/csv"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
@ -63,7 +52,7 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as any,
companyId: companyId as unknown as Id<"companies">,
})
const channels = report.channels

View file

@ -5,6 +5,8 @@ import { NextResponse } from "next/server"
// @ts-ignore no ambient types for this path; declared in types/
import PDFDocument from "pdfkit/js/pdfkit.standalone.js"
import { format } from "date-fns"
import fs from "fs"
import path from "path"
import { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
@ -123,21 +125,21 @@ function formatDurationMs(ms: number | null | undefined) {
return `${seconds}s`
}
function buildTimelineMessage(type: string, payload: any): string | null {
if (!payload || typeof payload !== "object") payload = {}
const to = payload.toLabel ?? payload.to
const assignee = payload.assigneeName ?? payload.assigneeId
const queue = payload.queueName ?? payload.queueId
const requester = payload.requesterName
const author = payload.authorName ?? payload.authorId
const actor = payload.actorName ?? payload.actorId
const attachmentName = payload.attachmentName
const subjectTo = payload.to
const pauseReason = payload.pauseReasonLabel ?? payload.pauseReason
const pauseNote = payload.pauseNote
const sessionDuration = formatDurationMs(payload.sessionDurationMs)
const categoryName = payload.categoryName
const subcategoryName = payload.subcategoryName
function buildTimelineMessage(type: string, payload: Record<string, unknown> | null | undefined): string | null {
const p = payload ?? {}
const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined)
const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined)
const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined)
const requester = p.requesterName as string | undefined
const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined)
const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined)
const attachmentName = p.attachmentName as string | undefined
const subjectTo = p.to as string | undefined
const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined)
const pauseNote = p.pauseNote as string | undefined
const sessionDuration = formatDurationMs((p.sessionDurationMs as number | undefined) ?? null)
const categoryName = p.categoryName as string | undefined
const subcategoryName = p.subcategoryName as string | undefined
switch (type) {
case "STATUS_CHANGED":
@ -247,8 +249,9 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const doc = new PDFDocument({ size: "A4", margin: 56 })
const chunks: Buffer[] = []
doc.on("data", (chunk: any) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
doc.on("data", (chunk: unknown) => {
const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)
chunks.push(buf)
})
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
@ -256,8 +259,48 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.on("error", reject)
})
// Register custom fonts (Inter) if available
try {
const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf")
const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf")
const fontRegular = fs.existsSync(pubRegular)
? pubRegular
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
const fontBold = fs.existsSync(pubBold)
? pubBold
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf")
const D = doc as unknown as {
registerFont?: (name: string, src: string) => void
_fontFamilies?: Record<string, unknown>
roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
if (fs.existsSync(fontRegular)) {
D.registerFont?.("Inter", fontRegular)
}
if (fs.existsSync(fontBold)) {
D.registerFont?.("Inter-Bold", fontBold)
}
} catch {}
const D = doc as unknown as { _fontFamilies?: Record<string, unknown>; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void }
const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record<string, unknown>)["Inter-Bold"])
// Header with logo and brand bar
try {
const logoPath = path.join(process.cwd(), "public", "rever-8.png")
if (fs.existsSync(logoPath)) {
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
}
} catch {}
doc.moveDown(0.5)
doc
.fillColor("#00e8ff")
.rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3)
.fill()
doc.moveDown(0.5)
// Título
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.25)
// Linha abaixo do título
doc
@ -276,30 +319,53 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const badgeX = doc.page.margins.left
const badgeY = doc.y
doc.save()
doc.font("Helvetica-Bold").fontSize(badgeFontSize)
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(badgeFontSize)
const badgeTextWidth = doc.widthOfString(statusText)
const badgeHeight = badgeFontSize + badgePaddingY * 2
const badgeWidth = badgeTextWidth + badgePaddingX * 2
;(doc as any).roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight)
D.roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight)
doc.fill(badgeColor)
doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY)
doc.restore()
doc.y = badgeY + badgeHeight + 8
// Metadados básicos
doc
.fillColor("#0F172A")
.font("Helvetica")
.fontSize(11)
.text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
// Metadados em duas colunas
const leftX = doc.page.margins.left
const colGap = 24
const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2
const rightX = leftX + colWidth + colGap
const startY = doc.y
const drawMeta = (x: number, lines: string[]) => {
doc.save()
doc.x = x
doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11)
for (const line of lines) {
doc.text(line, { width: colWidth, lineGap: 2 })
}
const currY = doc.y
doc.restore()
return currY
}
const leftLines = [
`Status: ${statusText}`,
`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`,
`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`,
`Fila: ${ticket.queue ?? "—"}`,
]
const rightLines = [
`Solicitante: ${ticket.requester.name} (${ticket.requester.email})`,
`Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`,
`Criado em: ${formatDateTime(ticket.createdAt)}`,
`Atualizado em: ${formatDateTime(ticket.updatedAt)}`,
]
const leftY = drawMeta(leftX, leftLines)
const rightY = drawMeta(rightX, rightLines)
doc.y = Math.max(leftY, rightY)
doc.moveDown(0.5)
doc.moveDown(0.75)
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(12)
.text("Solicitante")
doc
@ -309,12 +375,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(`${ticket.requester.name} (${ticket.requester.email})`)
doc.moveDown(0.5)
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
@ -322,7 +388,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
@ -345,58 +411,58 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.summary) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Resumo")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify", lineGap: 2 })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Descrição")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
}
if (ticket.comments.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.6)
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
commentsSorted.forEach((comment, index) => {
const visibility =
comment.visibility === "PUBLIC" ? "Público" : "Interno"
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(11)
.text(`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`)
doc.moveDown(0.15)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(body, { align: "justify", lineGap: 2, indent: 6 })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
doc.font("Helvetica").fontSize(10).text("Anexos:")
doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:")
comment.attachments.forEach((attachment) => {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
})
@ -415,13 +481,13 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.timeline.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.moveDown(0.6)
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
timelineSorted.forEach((event) => {
const label = timelineLabel[event.type] ?? event.type
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
doc.moveDown(0.15)
@ -429,14 +495,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const friendly = buildTimelineMessage(event.type, event.payload)
if (friendly) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(friendly, { indent: 16, lineGap: 1 })
} else {
const payloadText = stringifyPayload(event.payload)
if (payloadText) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(payloadText, { indent: 16, lineGap: 1 })
}