feat: link tickets in comments and align admin sidebars

This commit is contained in:
Esdras Renan 2025-10-23 00:46:50 -03:00
parent c35eb673d3
commit b0f57009ac
15 changed files with 1606 additions and 424 deletions

View file

@ -53,6 +53,100 @@ function plainTextLength(html: string): number {
}
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function buildAssigneeChangeComment(
reason: string,
context: { previousName: string; nextName: string },
): string {
const normalized = reason.replace(/\r\n/g, "\n").trim();
const lines = normalized
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const previous = escapeHtml(context.previousName || "Não atribuído");
const next = escapeHtml(context.nextName || "Não atribuído");
const reasonHtml = lines.length
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
: `<p>—</p>`;
return `<p><strong>Responsável atualizado:</strong> ${previous}${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
}
function truncateSubject(subject: string) {
if (subject.length <= 60) return subject
return `${subject.slice(0, 57)}`
}
function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
const reference = ticket.reference
const subject = escapeHtml(ticket.subject ?? "")
const truncated = truncateSubject(subject)
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase()
return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" href="/tickets/${String(ticket._id)}" class="ticket-mention" title="Chamado #${reference}${subject ? `${subject}` : ""}"><span class="ticket-mention-dot"></span><span class="ticket-mention-ref">#${reference}</span><span class="ticket-mention-sep">•</span><span class="ticket-mention-subject">${truncated}</span></a>`
}
function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true
if (viewerRole === "COLLABORATOR") {
return String(ticket.requesterId) === String(viewerId)
}
if (viewerRole === "MANAGER") {
// Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante
return String(ticket.requesterId) === String(viewerId)
}
return false
}
async function normalizeTicketMentions(
ctx: MutationCtx,
html: string,
viewer: { user: Doc<"users">; role: string },
tenantId: string,
): Promise<string> {
if (!html || html.indexOf("data-ticket-mention") === -1) {
return html
}
const mentionPattern = /<a\b[^>]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi
const matches = Array.from(html.matchAll(mentionPattern))
if (!matches.length) {
return html
}
let output = html
for (const match of matches) {
const full = match[0]
const idMatch = /data-ticket-id="([^"]+)"/i.exec(full)
const ticketIdRaw = idMatch?.[1]
let replacement = ""
if (ticketIdRaw) {
const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">)
if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) {
replacement = buildTicketMentionAnchor(ticket)
} else {
const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
replacement = escapeHtml(inner || `#${ticketIdRaw}`)
}
} else {
replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim())
}
output = output.replace(full, replacement)
}
return output
}
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
@ -1170,11 +1264,6 @@ export const addComment = mutation({
}
}
const bodyPlainLen = plainTextLength(args.body)
if (bodyPlainLen > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const authorSnapshot: CommentAuthorSnapshot = {
name: author.name,
email: author.email,
@ -1182,12 +1271,18 @@ export const addComment = mutation({
teams: author.teams ?? undefined,
};
const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId)
const bodyPlainLen = plainTextLength(normalizedBody)
if (bodyPlainLen > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const now = Date.now();
const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId,
authorId: args.authorId,
visibility: requestedVisibility,
body: args.body,
body: normalizedBody,
authorSnapshot,
attachments,
createdAt: now,
@ -1240,14 +1335,15 @@ export const updateComment = mutation({
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const bodyPlainLen = plainTextLength(body)
const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId)
const bodyPlainLen = plainTextLength(normalizedBody)
if (bodyPlainLen > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const now = Date.now();
await ctx.db.patch(commentId, {
body,
body: normalizedBody,
updatedAt: now,
});
@ -1356,14 +1452,20 @@ export const updateStatus = mutation({
});
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
args: {
ticketId: v.id("tickets"),
assigneeId: v.id("users"),
actorId: v.id("users"),
reason: v.string(),
},
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const viewerUser = viewer.user
const isAdmin = viewer.role === "ADMIN"
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
@ -1381,6 +1483,26 @@ export const changeAssignee = mutation({
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
}
const normalizedReason = reason.replace(/\r\n/g, "\n").trim()
if (normalizedReason.length < 5) {
throw new ConvexError("Informe um motivo para registrar a troca de responsável")
}
if (normalizedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
}
const previousAssigneeName =
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
"Não atribuído"
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
const commentBody = buildAssigneeChangeComment(normalizedReason, {
previousName: previousAssigneeName,
nextName: nextAssigneeName,
})
const commentPlainLength = plainTextLength(commentBody)
if (commentPlainLength > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const now = Date.now();
const assigneeSnapshot = {
name: assignee.name,
@ -1392,9 +1514,39 @@ export const changeAssignee = mutation({
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: assignee.name, actorId },
payload: {
assigneeId,
assigneeName: assignee.name,
actorId,
previousAssigneeId: currentAssigneeId,
previousAssigneeName,
reason: normalizedReason,
},
createdAt: now,
});
const authorSnapshot: CommentAuthorSnapshot = {
name: viewerUser.name,
email: viewerUser.email,
avatarUrl: viewerUser.avatarUrl ?? undefined,
teams: viewerUser.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId,
authorId: actorId,
visibility: "INTERNAL",
body: commentBody,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "COMMENT_ADDED",
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
createdAt: now,
})
},
});