feat: link tickets in comments and align admin sidebars
This commit is contained in:
parent
c35eb673d3
commit
b0f57009ac
15 changed files with 1606 additions and 424 deletions
|
|
@ -53,6 +53,100 @@ function plainTextLength(html: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue