feat(tickets): preserve requester/assignee/company snapshots + timeline fallbacks; chore: add requester index\n\n- Add requesterSnapshot, assigneeSnapshot, companySnapshot to tickets\n- Use snapshots as fallback in list/get/play\n- Update snapshots on assignee changes/startWork\n- Preserve snapshots before deleting users/companies\n- Add index tickets.by_tenant_requester\n- Add migrations.backfillTicketSnapshots\n\nchore(convex): upgrade to ^1.28.0 and run codegen\nchore(next): upgrade Next.js to 15.5.6 and update React/eslint-config-next\nfix: remove any and lint warnings; tighten types across API routes and components\ndocs: add docs/ticket-snapshots.md

This commit is contained in:
Esdras Renan 2025-10-20 10:13:37 -03:00
parent 0d82162a0e
commit 216feca971
16 changed files with 884 additions and 552 deletions

View file

@ -160,6 +160,57 @@ function buildRequesterSummary(
};
}
type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] };
type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean };
function buildRequesterFromSnapshot(
requesterId: Id<"users">,
snapshot: UserSnapshot | null | undefined,
fallback?: RequesterFallbackContext
) {
if (snapshot) {
const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado")
const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`)
return {
id: requesterId,
name,
email,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot.teams ?? []),
}
}
return buildRequesterSummary(null, requesterId, fallback)
}
function buildAssigneeFromSnapshot(
assigneeId: Id<"users">,
snapshot: UserSnapshot | null | undefined
) {
const name = snapshot?.name?.trim?.() || "Usuário removido"
const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid`
return {
id: assigneeId,
name,
email,
avatarUrl: snapshot?.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot?.teams ?? []),
}
}
function buildCompanyFromSnapshot(
companyId: Id<"companies"> | undefined,
snapshot: CompanySnapshot | null | undefined
) {
if (!snapshot) return null
return {
id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">,
name: snapshot.name,
isAvulso: Boolean(snapshot.isAvulso ?? false),
}
}
type CommentAuthorFallbackContext = {
ticketId?: Id<"tickets">;
commentId?: Id<"ticketComments">;
@ -472,16 +523,28 @@ export const list = query({
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
company: company
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
: t.companyId || t.companySnapshot
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
: null,
requester: requester
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
: buildRequesterFromSnapshot(
t.requesterId,
t.requesterSnapshot ?? undefined,
{ ticketId: t._id }
),
assignee: t.assigneeId
? assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
@ -644,16 +707,28 @@ export const getById = query({
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
company: company
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
: t.companyId || t.companySnapshot
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
: null,
requester: requester
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
: buildRequesterFromSnapshot(
t.requesterId,
t.requesterSnapshot ?? undefined,
{ ticketId: t._id }
),
assignee: t.assigneeId
? assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
@ -798,6 +873,26 @@ export const create = mutation({
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
const requesterSnapshot = {
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
const companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const assigneeSnapshot = initialAssignee
? {
name: initialAssignee.name,
email: initialAssignee.email,
avatarUrl: initialAssignee.avatarUrl ?? undefined,
teams: initialAssignee.teams ?? undefined,
}
: undefined
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
@ -810,8 +905,11 @@ export const create = mutation({
categoryId: args.categoryId,
subcategoryId: args.subcategoryId,
requesterId: args.requesterId,
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: requester.companyId ?? undefined,
companySnapshot,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
@ -1119,7 +1217,13 @@ export const changeAssignee = mutation({
}
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const assigneeSnapshot = {
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl ?? undefined,
teams: assignee.teams ?? undefined,
}
await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
@ -1328,7 +1432,13 @@ export const startWork = mutation({
let assigneePatched = false
if (!currentAssigneeId) {
await ctx.db.patch(ticketId, { assigneeId: actorId, updatedAt: now })
const assigneeSnapshot = {
name: viewer.user.name,
email: viewer.user.email,
avatarUrl: viewer.user.avatarUrl ?? undefined,
teams: viewer.user.teams ?? undefined,
}
await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now })
ticketDoc.assigneeId = actorId
assigneePatched = true
}
@ -1542,7 +1652,13 @@ export const playNext = mutation({
const currentStatus = normalizeStatus(chosen.status);
const nextStatus: TicketStatusNormalized =
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: nextStatus, updatedAt: now });
const assigneeSnapshot = {
name: agent.name,
email: agent.email,
avatarUrl: agent.avatarUrl ?? undefined,
teams: agent.teams ?? undefined,
}
await ctx.db.patch(chosen._id, { assigneeId: agentId, assigneeSnapshot, status: nextStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
@ -1565,15 +1681,23 @@ export const playNext = mutation({
priority: chosen.priority,
channel: chosen.channel,
queue: queueName,
requester: buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id }),
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
requester: requester
? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id })
: buildRequesterFromSnapshot(
chosen.requesterId,
chosen.requesterSnapshot ?? undefined,
{ ticketId: chosen._id }
),
assignee: chosen.assigneeId
? assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
: buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined)
: null,
slaPolicy: null,
dueAt: chosen.dueAt ?? null,