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:
parent
59e9298d61
commit
9d1908a5aa
6 changed files with 193 additions and 78 deletions
|
|
@ -1043,3 +1043,81 @@ export const backfillTicketSnapshots = mutation({
|
||||||
return { processed }
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function truncateSubject(subject: string) {
|
||||||
if (subject.length <= 60) return subject
|
if (subject.length <= 60) return subject
|
||||||
return `${subject.slice(0, 57)}…`
|
return `${subject.slice(0, 57)}…`
|
||||||
|
|
@ -3475,38 +3458,6 @@ export const changeAssignee = mutation({
|
||||||
createdAt: now,
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
export const assignCompany = mutation({
|
||||||
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
import { getServerSession } from "@/lib/auth-server"
|
import { getServerSession } from "@/lib/auth-server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { createConvexClient } from "@/server/convex-client"
|
import { createConvexClient } from "@/server/convex-client"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
@ -78,6 +79,18 @@ export async function POST(request: NextRequest) {
|
||||||
data: { avatarUrl },
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
|
@ -102,6 +115,19 @@ export async function DELETE() {
|
||||||
data: { avatarUrl: null },
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Foto removida com sucesso",
|
message: "Foto removida com sucesso",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,10 @@ const serverTicketSchema = z.object({
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
text: 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(),
|
done: z.boolean(),
|
||||||
required: z.boolean().optional(),
|
required: z.boolean().optional(),
|
||||||
templateId: z.string().optional(),
|
templateId: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -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("<Ana>")
|
|
||||||
expect(html).toContain("Bruno & Co")
|
|
||||||
expect(html).not.toContain("<script>")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue