chore: snapshot comment authors before user deletion
This commit is contained in:
parent
846e575637
commit
63d6a65334
6 changed files with 319 additions and 5976 deletions
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
115
tests/ticket-comments.test.ts
Normal file
115
tests/ticket-comments.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue