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("