fix: remove duplicacao de comentario na troca de responsavel e corrige avatar

- Remove criacao automatica de comentario ao trocar responsavel (ja aparece na timeline)
- Adiciona migration removeAssigneeChangeComments para limpar comentarios antigos
- Adiciona campos description, type, options, answer ao schema de checklist no mapper
- Cria mutation updateAvatar no Convex para sincronizar avatar com snapshots
- Atualiza rota /api/profile/avatar para sincronizar com Convex ao adicionar/remover foto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-15 18:53:49 -03:00
parent 59e9298d61
commit 9d1908a5aa
6 changed files with 193 additions and 78 deletions

View file

@ -1043,3 +1043,81 @@ export const backfillTicketSnapshots = mutation({
return { processed }
},
})
/**
* Migration para remover comentarios duplicados de troca de responsavel.
* Esses comentarios eram criados automaticamente ao trocar o responsavel,
* mas essa informacao ja aparece na linha do tempo (ticketEvents).
* O comentario segue o padrao: "<p><strong>Responsável atualizado:</strong>..."
*/
export const removeAssigneeChangeComments = mutation({
args: {
tenantId: v.optional(v.string()),
limit: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, limit, dryRun }) => {
const effectiveDryRun = Boolean(dryRun)
const effectiveLimit = limit && limit > 0 ? Math.min(limit, 500) : 500
// Busca comentarios internos que contenham o padrao de troca de responsavel
const comments = tenantId && tenantId.trim().length > 0
? await ctx.db.query("ticketComments").take(5000)
: await ctx.db.query("ticketComments").take(5000)
// Filtrar comentarios que sao de troca de responsavel
const assigneeChangePattern = "<p><strong>Responsável atualizado:</strong>"
const toDelete = comments.filter((comment) => {
if (comment.visibility !== "INTERNAL") return false
if (typeof comment.body !== "string") return false
return comment.body.includes(assigneeChangePattern)
})
// Filtrar por tenant se especificado
let filtered = toDelete
if (tenantId && tenantId.trim().length > 0) {
const ticketIds = new Set<string>()
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(10000)
for (const t of tickets) {
ticketIds.add(t._id)
}
filtered = toDelete.filter((c) => ticketIds.has(c.ticketId))
}
const limitedComments = filtered.slice(0, effectiveLimit)
let deleted = 0
let eventsDeleted = 0
for (const comment of limitedComments) {
if (!effectiveDryRun) {
// Deletar o evento COMMENT_ADDED correspondente
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
.take(500)
const matchingEvent = events.find(
(event) =>
event.type === "COMMENT_ADDED" &&
Math.abs(event.createdAt - comment.createdAt) < 1000, // mesmo timestamp (tolerancia de 1s)
)
if (matchingEvent) {
await ctx.db.delete(matchingEvent._id)
eventsDeleted += 1
}
await ctx.db.delete(comment._id)
}
deleted += 1
}
return {
dryRun: effectiveDryRun,
totalFound: filtered.length,
deleted,
eventsDeleted,
remaining: filtered.length - deleted,
}
},
})

View file

@ -872,23 +872,6 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
}
}
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)}`
@ -3475,38 +3458,6 @@ export const changeAssignee = mutation({
createdAt: now,
});
if (normalizedReason.length > 0) {
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 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,
})
}
},
});

View file

@ -279,6 +279,91 @@ export const deleteUser = mutation({
},
});
/**
* Atualiza o avatar de um usuário.
* Passa avatarUrl como null para remover o avatar.
* Também atualiza os snapshots em comentários e tickets.
*/
export const updateAvatar = mutation({
args: {
tenantId: v.string(),
email: v.string(),
avatarUrl: v.union(v.string(), v.null()),
},
handler: async (ctx, { tenantId, email, avatarUrl }) => {
const user = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
.first()
if (!user) {
return { status: "not_found" }
}
// Atualiza o avatar do usuário
const normalizedAvatarUrl = avatarUrl ?? undefined
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
// Atualiza snapshots em comentários
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_author", (q) => q.eq("authorId", user._id))
.take(10000)
if (comments.length > 0) {
const authorSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
await Promise.all(
comments.map(async (comment) => {
await ctx.db.patch(comment._id, { authorSnapshot })
}),
)
}
// Atualiza snapshots de requester em tickets
const requesterTickets = await ctx.db
.query("tickets")
.withIndex("by_requester", (q) => q.eq("requesterId", user._id))
.take(10000)
if (requesterTickets.length > 0) {
const requesterSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
for (const t of requesterTickets) {
await ctx.db.patch(t._id, { requesterSnapshot })
}
}
// Atualiza snapshots de assignee em tickets
const assigneeTickets = await ctx.db
.query("tickets")
.withIndex("by_assignee", (q) => q.eq("assigneeId", user._id))
.take(10000)
if (assigneeTickets.length > 0) {
const assigneeSnapshot = {
name: user.name,
email: user.email,
avatarUrl: normalizedAvatarUrl,
teams: user.teams ?? undefined,
}
for (const t of assigneeTickets) {
await ctx.db.patch(t._id, { assigneeSnapshot })
}
}
return { status: "updated", avatarUrl: normalizedAvatarUrl }
},
})
export const assignCompany = mutation({
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
handler: async (ctx, { tenantId, email, companyId, actorId }) => {

View file

@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "@/lib/auth-server"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { createConvexClient } from "@/server/convex-client"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -78,6 +79,18 @@ export async function POST(request: NextRequest) {
data: { avatarUrl },
})
// Sincroniza com o Convex
try {
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
await convex.mutation(api.users.updateAvatar, {
tenantId,
email: session.user.email,
avatarUrl,
})
} catch (error) {
console.warn("[profile/avatar] Falha ao sincronizar avatar no Convex:", error)
}
return NextResponse.json({
success: true,
avatarUrl,
@ -102,6 +115,19 @@ export async function DELETE() {
data: { avatarUrl: null },
})
// Sincroniza com o Convex
try {
const convex = createConvexClient()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
await convex.mutation(api.users.updateAvatar, {
tenantId,
email: session.user.email,
avatarUrl: null,
})
} catch (error) {
console.warn("[profile/avatar] Falha ao sincronizar remoção de avatar no Convex:", error)
}
return NextResponse.json({
success: true,
message: "Foto removida com sucesso",

View file

@ -86,6 +86,10 @@ const serverTicketSchema = z.object({
z.object({
id: z.string(),
text: z.string(),
description: z.string().optional(),
type: z.enum(["checkbox", "question"]).optional(),
options: z.array(z.string()).optional(),
answer: z.string().optional(),
done: z.boolean(),
required: z.boolean().optional(),
templateId: z.string().optional(),

View file

@ -1,29 +0,0 @@
import { describe, expect, it } from "bun:test"
import { buildAssigneeChangeComment } from "../convex/tickets"
describe("buildAssigneeChangeComment", () => {
it("inclui nomes antigos e novos e quebra o motivo em parágrafos", () => {
const html = buildAssigneeChangeComment("Transferir para o time B\nCliente solicitou gestor.", {
previousName: "Ana",
nextName: "Bruno",
})
expect(html).toContain("Ana")
expect(html).toContain("Bruno")
expect(html).toContain("<p>Transferir para o time B</p>")
expect(html).toContain("<p>Cliente solicitou gestor.</p>")
})
it("escapa caracteres perigosos", () => {
const html = buildAssigneeChangeComment("<script>alert(1)</script>", {
previousName: "<Ana>",
nextName: "Bruno & Co",
})
expect(html).toContain("&lt;Ana&gt;")
expect(html).toContain("Bruno &amp; Co")
expect(html).not.toContain("<script>")
})
})