feat: enhance tickets portal and admin flows

This commit is contained in:
Esdras Renan 2025-10-07 02:26:09 -03:00
parent 9cdd8763b4
commit c15f0a5b09
67 changed files with 1101 additions and 338 deletions

View file

@ -1,8 +1,8 @@
import { NextResponse } from "next/server"
import { Prisma } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
@ -36,7 +36,8 @@ async function syncInvite(invite: NormalizedInvite) {
})
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
@ -46,7 +47,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
const invite = await prisma.authInvite.findUnique({
where: { id: params.id },
where: { id },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -81,7 +82,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
data: {
inviteId: invite.id,
type: "revoked",
payload: reason ? { reason } : null,
payload: reason ? { reason } : Prisma.JsonNull,
actorId: session.user.id ?? null,
},
})

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { Prisma } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -14,6 +14,13 @@ import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type Norma
const DEFAULT_EXPIRATION_DAYS = 7
function toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (payload === null || payload === undefined) {
return Prisma.JsonNull
}
return payload as Prisma.InputJsonValue
}
function normalizeRole(input: string | null | undefined): RoleOption {
const role = (input ?? "agent").toLowerCase() as RoleOption
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
@ -52,7 +59,7 @@ async function appendEvent(inviteId: string, type: string, actorId: string | nul
data: {
inviteId,
type,
payload,
payload: toJsonPayload(payload),
actorId,
},
})

View file

@ -4,7 +4,6 @@ import { randomBytes } from "crypto"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { Prisma } from "@prisma/client"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks types in Next routes
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
@ -47,9 +47,10 @@ async function syncInvite(invite: NormalizedInvite) {
})
}
export async function GET(_request: Request, { params }: { params: { token: string } }) {
export async function GET(_request: Request, context: { params: Promise<{ token: string }> }) {
const { token } = await context.params
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
where: { token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -66,7 +67,7 @@ export async function GET(_request: Request, { params }: { params: { token: stri
data: {
inviteId: invite.id,
type: status,
payload: null,
payload: Prisma.JsonNull,
actorId: null,
},
})
@ -80,7 +81,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri
return NextResponse.json({ invite: normalized })
}
export async function POST(request: Request, { params }: { params: { token: string } }) {
export async function POST(request: Request, context: { params: Promise<{ token: string }> }) {
const { token } = await context.params
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
if (!payload || typeof payload.password !== "string") {
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
@ -91,7 +93,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
}
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
where: { token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -108,7 +110,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
data: {
inviteId: invite.id,
type: "expired",
payload: null,
payload: Prisma.JsonNull,
actorId: null,
},
})

View file

@ -0,0 +1,261 @@
import { NextResponse } from "next/server"
import PDFDocument from "pdfkit"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertStaffSession } from "@/lib/auth-server"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const timelineLabel: Record<string, string> = {
CREATED: "Chamado criado",
STATUS_CHANGED: "Status atualizado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Novo comentário",
COMMENT_EDITED: "Comentário editado",
ATTACHMENT_REMOVED: "Anexo removido",
QUEUE_CHANGED: "Fila alterada",
PRIORITY_CHANGED: "Prioridade alterada",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
CATEGORY_CHANGED: "Categoria alterada",
}
function formatDateTime(date: Date | null | undefined) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
function htmlToPlainText(html?: string | null) {
if (!html) return ""
const withBreaks = html
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
const stripped = withBreaks.replace(/<[^>]+>/g, "")
return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim()
}
function decodeHtmlEntities(input: string) {
return input
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
}
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
const { id: ticketId } = await context.params
const session = await assertStaffSession()
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 tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
let viewerId: string | null = null
try {
const ensuredUser = 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 = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for PDF export", 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 })
}
let ticketRaw: unknown
try {
ticketRaw = await client.query(api.tickets.getById, {
tenantId,
id: ticketId as unknown as Id<"tickets">,
viewerId: viewerId as unknown as Id<"users">,
})
} catch (error) {
console.error("Failed to load ticket from Convex for PDF export", error, {
tenantId,
ticketId,
viewerId,
})
return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 })
}
if (!ticketRaw) {
return NextResponse.json({ error: "Ticket não encontrado" }, { status: 404 })
}
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
const doc = new PDFDocument({ size: "A4", margin: 48 })
const chunks: Buffer[] = []
doc.on("data", (chunk) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
})
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)))
doc.on("error", reject)
})
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.5)
doc
.font("Helvetica")
.fontSize(11)
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
.moveDown(0.15)
.text(`Prioridade: ${ticket.priority}`)
.moveDown(0.15)
.text(`Canal: ${ticket.channel}`)
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`)
doc.moveDown(0.75)
doc
.font("Helvetica-Bold")
.fontSize(12)
.text("Solicitante")
doc
.font("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("Helvetica")
.fontSize(11)
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
doc
.font("Helvetica")
.fontSize(11)
.text(`Criado em: ${formatDateTime(ticket.createdAt)}`)
.moveDown(0.15)
.text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`)
.moveDown(0.15)
.text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`)
if (ticket.summary) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
doc
.font("Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify" })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
doc
.font("Helvetica")
.fontSize(11)
.text(htmlToPlainText(ticket.description), { align: "justify" })
}
if (ticket.comments.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.5)
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")
.fontSize(11)
.text(
`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`
)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.fontSize(11)
.text(body, { align: "justify" })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
doc.font("Helvetica").fontSize(10).text("Anexos:")
comment.attachments.forEach((attachment) => {
doc
.font("Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 12 })
})
}
if (index < commentsSorted.length - 1) {
doc.moveDown(0.75)
doc
.strokeColor("#E2E8F0")
.moveTo(doc.x, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.75)
}
})
}
if (ticket.timeline.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.moveDown(0.5)
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")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
if (event.payload) {
const payloadText = JSON.stringify(event.payload, null, 2)
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 12 })
}
doc.moveDown(0.5)
})
}
doc.end()
const pdfBuffer = await pdfBufferPromise
const pdfBytes = new Uint8Array(pdfBuffer)
return new NextResponse(pdfBytes, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
"Cache-Control": "no-store",
},
})
}