feat: enhance tickets portal and admin flows
This commit is contained in:
parent
9cdd8763b4
commit
c15f0a5b09
67 changed files with 1101 additions and 338 deletions
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
261
src/app/api/tickets/[id]/export/pdf/route.ts
Normal file
261
src/app/api/tickets/[id]/export/pdf/route.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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",
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue