chore: snapshot comment authors before user deletion

This commit is contained in:
Esdras Renan 2025-10-19 14:30:59 -03:00
parent 846e575637
commit 63d6a65334
6 changed files with 319 additions and 5976 deletions

5965
RENAN.txt

File diff suppressed because it is too large Load diff

View file

@ -642,6 +642,79 @@ export const importPrismaSnapshot = mutation({
}, },
}) })
export const backfillTicketCommentAuthorSnapshots = mutation({
args: {
limit: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, { limit, dryRun }) => {
const effectiveDryRun = Boolean(dryRun)
const maxUpdates = limit && limit > 0 ? limit : null
const comments = await ctx.db.query("ticketComments").collect()
let updated = 0
let skippedExisting = 0
let missingAuthors = 0
for (const comment of comments) {
if (comment.authorSnapshot) {
skippedExisting += 1
continue
}
if (maxUpdates !== null && updated >= maxUpdates) {
break
}
const author = await ctx.db.get(comment.authorId)
let name: string | null = author?.name ?? null
const email: string | null = author?.email ?? null
let avatarUrl: string | null = author?.avatarUrl ?? null
const teams: string[] | undefined = (author?.teams ?? undefined) as string[] | undefined
if (!author) {
missingAuthors += 1
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
.collect()
const matchingEvent = events.find(
(event) => event.type === "COMMENT_ADDED" && event.createdAt === comment.createdAt,
)
if (matchingEvent && matchingEvent.payload && typeof matchingEvent.payload === "object") {
const payload = matchingEvent.payload as { authorName?: string; authorAvatar?: string }
if (typeof payload.authorName === "string" && payload.authorName.trim().length > 0) {
name = payload.authorName.trim()
}
if (typeof payload.authorAvatar === "string" && payload.authorAvatar.trim().length > 0) {
avatarUrl = payload.authorAvatar
}
}
}
const snapshot = pruneUndefined({
name: name && name.trim().length > 0 ? name : "Usuário removido",
email: email ?? undefined,
avatarUrl: avatarUrl ?? undefined,
teams: teams && teams.length > 0 ? teams : undefined,
})
if (!effectiveDryRun) {
await ctx.db.patch(comment._id, { authorSnapshot: snapshot })
}
updated += 1
}
return {
dryRun: effectiveDryRun,
totalComments: comments.length,
updated,
skippedExisting,
missingAuthors,
limit: maxUpdates,
}
},
})
export const syncMachineCompanyReferences = mutation({ export const syncMachineCompanyReferences = mutation({
args: { args: {
tenantId: v.optional(v.string()), tenantId: v.optional(v.string()),

View file

@ -125,6 +125,14 @@ export default defineSchema({
authorId: v.id("users"), authorId: v.id("users"),
visibility: v.string(), // PUBLIC | INTERNAL visibility: v.string(), // PUBLIC | INTERNAL
body: v.string(), body: v.string(),
authorSnapshot: v.optional(
v.object({
name: v.string(),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
})
),
attachments: v.optional( attachments: v.optional(
v.array( v.array(
v.object({ v.object({
@ -137,7 +145,9 @@ export default defineSchema({
), ),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}).index("by_ticket", ["ticketId"]), })
.index("by_ticket", ["ticketId"])
.index("by_author", ["authorId"]),
ticketEvents: defineTable({ ticketEvents: defineTable({
ticketId: v.id("tickets"), ticketId: v.id("tickets"),

View file

@ -33,6 +33,9 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
CLOSED: "RESOLVED", CLOSED: "RESOLVED",
}; };
const missingRequesterLogCache = new Set<string>();
const missingCommentAuthorLogCache = new Set<string>();
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING"; if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
@ -140,9 +143,13 @@ function buildRequesterSummary(
if (process.env.NODE_ENV !== "test") { if (process.env.NODE_ENV !== "test") {
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : ""; const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
console.warn( const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`;
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`, if (!missingRequesterLogCache.has(cacheKey)) {
); missingRequesterLogCache.add(cacheKey);
console.warn(
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
);
}
} }
return { return {
@ -153,6 +160,76 @@ function buildRequesterSummary(
}; };
} }
type CommentAuthorFallbackContext = {
ticketId?: Id<"tickets">;
commentId?: Id<"ticketComments">;
};
type CommentAuthorSnapshot = {
name: string;
email?: string;
avatarUrl?: string;
teams?: string[];
};
export function buildCommentAuthorSummary(
comment: Doc<"ticketComments">,
author: Doc<"users"> | null,
context?: CommentAuthorFallbackContext,
) {
if (author) {
return {
id: author._id,
name: author.name,
email: author.email,
avatarUrl: author.avatarUrl,
teams: normalizeTeams(author.teams),
};
}
if (process.env.NODE_ENV !== "test") {
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : "";
const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"];
if (context?.commentId) cacheKeyParts.push(String(context.commentId));
const cacheKey = cacheKeyParts.join(":");
if (!missingCommentAuthorLogCache.has(cacheKey)) {
missingCommentAuthorLogCache.add(cacheKey);
console.warn(
`[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`,
);
}
}
const idString = String(comment.authorId);
const fallbackName = "Usuário removido";
const fallbackEmail = `author-${idString}@example.invalid`;
const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined;
if (snapshot) {
const name =
typeof snapshot.name === "string" && snapshot.name.trim().length > 0
? snapshot.name.trim()
: fallbackName;
const emailCandidate =
typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null;
const email = emailCandidate ?? fallbackEmail;
return {
id: comment.authorId,
name,
email,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot.teams ?? []),
};
}
return {
id: comment.authorId,
name: fallbackName,
email: fallbackEmail,
teams: [],
};
}
type CustomFieldInput = { type CustomFieldInput = {
fieldId: Id<"ticketFields">; fieldId: Id<"ticketFields">;
value: unknown; value: unknown;
@ -536,15 +613,13 @@ export const getById = query({
url: await ctx.storage.getUrl(att.storageId), url: await ctx.storage.getUrl(att.storageId),
})) }))
); );
const authorSummary = buildCommentAuthorSummary(c, author, {
ticketId: t._id,
commentId: c._id,
});
return { return {
id: c._id, id: c._id,
author: { author: authorSummary,
id: author!._id,
name: author!.name,
email: author!.email,
avatarUrl: author!.avatarUrl,
teams: author!.teams ?? [],
},
visibility: c.visibility, visibility: c.visibility,
body: c.body, body: c.body,
attachments, attachments,
@ -842,12 +917,20 @@ export const addComment = mutation({
} }
} }
const authorSnapshot: CommentAuthorSnapshot = {
name: author.name,
email: author.email,
avatarUrl: author.avatarUrl ?? undefined,
teams: author.teams ?? undefined,
};
const now = Date.now(); const now = Date.now();
const id = await ctx.db.insert("ticketComments", { const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId, ticketId: args.ticketId,
authorId: args.authorId, authorId: args.authorId,
visibility: requestedVisibility, visibility: requestedVisibility,
body: args.body, body: args.body,
authorSnapshot,
attachments, attachments,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View file

@ -118,6 +118,33 @@ export const deleteUser = mutation({
throw new ConvexError("Usuário ainda está atribuído a tickets"); throw new ConvexError("Usuário ainda está atribuído a tickets");
} }
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_author", (q) => q.eq("authorId", userId))
.collect();
if (comments.length > 0) {
const authorSnapshot = {
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl ?? undefined,
teams: user.teams ?? undefined,
};
await Promise.all(
comments.map(async (comment) => {
const existingSnapshot = comment.authorSnapshot;
const shouldUpdate =
!existingSnapshot ||
existingSnapshot.name !== authorSnapshot.name ||
existingSnapshot.email !== authorSnapshot.email ||
existingSnapshot.avatarUrl !== authorSnapshot.avatarUrl ||
JSON.stringify(existingSnapshot.teams ?? []) !== JSON.stringify(authorSnapshot.teams ?? []);
if (shouldUpdate) {
await ctx.db.patch(comment._id, { authorSnapshot });
}
}),
);
}
await ctx.db.delete(userId); await ctx.db.delete(userId);
return { status: "deleted" }; return { status: "deleted" };
}, },

View file

@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest"
import { buildCommentAuthorSummary } from "../convex/tickets"
import type { Doc, Id } from "../convex/_generated/dataModel"
function makeId<TableName extends string>(value: string) {
return value as unknown as Id<TableName>
}
function makeComment(overrides: Partial<Doc<"ticketComments">> = {}) {
const base = {
_id: makeId<"ticketComments">(`comment-${Math.random().toString(16).slice(2)}`),
_creationTime: Date.now(),
ticketId: makeId<"tickets">(`ticket-${Math.random().toString(16).slice(2)}`),
authorId: makeId<"users">(`user-${Math.random().toString(16).slice(2)}`),
visibility: "PUBLIC",
body: "Teste",
attachments: [],
createdAt: Date.now(),
updatedAt: Date.now(),
} satisfies Partial<Doc<"ticketComments">>
return { ...base, ...overrides } as Doc<"ticketComments">
}
function makeUser(overrides: Partial<Doc<"users">> = {}) {
const base = {
_id: makeId<"users">(`user-${Math.random().toString(16).slice(2)}`),
_creationTime: Date.now(),
tenantId: "tenant-1",
email: "autor@example.com",
name: "Autor",
role: "AGENT",
teams: ["Suporte N1"],
} satisfies Partial<Doc<"users">>
return { ...base, ...overrides } as Doc<"users">
}
describe("buildCommentAuthorSummary", () => {
it("retorna dados do autor quando o usuário existe", () => {
const user = makeUser({
name: "Ana",
email: "ana@example.com",
avatarUrl: "https://example.com/avatar.png",
teams: ["Suporte N1"],
})
const comment = makeComment({
authorId: user._id,
ticketId: makeId<"tickets">("ticket-existing"),
})
const summary = buildCommentAuthorSummary(comment, user, {
ticketId: comment.ticketId,
commentId: comment._id,
})
expect(summary).toMatchObject({
id: user._id,
name: "Ana",
email: "ana@example.com",
avatarUrl: "https://example.com/avatar.png",
teams: ["Chamados"],
})
})
it("usa snapshot quando o autor não existe mais", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const comment = makeComment({
_id: makeId<"ticketComments">("comment-snapshot"),
ticketId: makeId<"tickets">("ticket-snapshot"),
authorId: makeId<"users">("user-snapshot"),
authorSnapshot: {
name: "Bruno Antigo",
email: "bruno@example.com",
avatarUrl: "https://example.com/bruno.png",
teams: ["Suporte N2"],
},
})
const summary = buildCommentAuthorSummary(comment, null, {
ticketId: comment.ticketId,
commentId: comment._id,
})
expect(summary).toMatchObject({
id: comment.authorId,
name: "Bruno Antigo",
email: "bruno@example.com",
avatarUrl: "https://example.com/bruno.png",
teams: ["Laboratório"],
})
warn.mockRestore()
})
it("gera placeholder quando não há snapshot disponível", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const comment = makeComment({
_id: makeId<"ticketComments">("comment-placeholder"),
ticketId: makeId<"tickets">("ticket-placeholder"),
authorId: makeId<"users">("user-placeholder"),
})
const summary = buildCommentAuthorSummary(comment, null, {
ticketId: comment.ticketId,
commentId: comment._id,
})
expect(summary).toMatchObject({
id: comment.authorId,
name: "Usuário removido",
email: "author-user-placeholder@example.invalid",
teams: [],
})
warn.mockRestore()
})
})