From 9d1908a5aa051c7ec9c7190cf031162ce4358919 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 15 Dec 2025 18:53:49 -0300 Subject: [PATCH] fix: remove duplicacao de comentario na troca de responsavel e corrige avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- convex/migrations.ts | 78 ++++++++++++++++++++++++ convex/tickets.ts | 49 --------------- convex/users.ts | 85 +++++++++++++++++++++++++++ src/app/api/profile/avatar/route.ts | 26 ++++++++ src/lib/mappers/ticket.ts | 4 ++ tests/change-assignee-comment.test.ts | 29 --------- 6 files changed, 193 insertions(+), 78 deletions(-) delete mode 100644 tests/change-assignee-comment.test.ts diff --git a/convex/migrations.ts b/convex/migrations.ts index 9ea35e6..6127128 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -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: "

Responsável atualizado:..." + */ +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 = "

Responsável atualizado:" + 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() + 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, + } + }, +}) diff --git a/convex/tickets.ts b/convex/tickets.ts index 881dce8..3575edb 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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) => `

${escapeHtml(line)}

`).join("") - : `

—

`; - return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${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, - }) - } }, }); diff --git a/convex/users.ts b/convex/users.ts index e435da6..a183556 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -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 }) => { diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts index d6e980c..e265688 100644 --- a/src/app/api/profile/avatar/route.ts +++ b/src/app/api/profile/avatar/route.ts @@ -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", diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index 0baec56..f83def8 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -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(), diff --git a/tests/change-assignee-comment.test.ts b/tests/change-assignee-comment.test.ts deleted file mode 100644 index d9e18f9..0000000 --- a/tests/change-assignee-comment.test.ts +++ /dev/null @@ -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("

Transferir para o time B

") - expect(html).toContain("

Cliente solicitou gestor.

") - }) - - it("escapa caracteres perigosos", () => { - const html = buildAssigneeChangeComment("", { - previousName: "", - nextName: "Bruno & Co", - }) - - expect(html).toContain("<Ana>") - expect(html).toContain("Bruno & Co") - expect(html).not.toContain("