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:
parent
0d82162a0e
commit
216feca971
16 changed files with 884 additions and 552 deletions
|
|
@ -102,6 +102,28 @@ export const removeBySlug = mutation({
|
|||
return { removed: false }
|
||||
}
|
||||
|
||||
// Preserve company snapshot on related tickets before deletion
|
||||
const relatedTickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", existing._id))
|
||||
.collect()
|
||||
if (relatedTickets.length > 0) {
|
||||
const companySnapshot = {
|
||||
name: existing.name,
|
||||
slug: existing.slug,
|
||||
isAvulso: existing.isAvulso ?? undefined,
|
||||
}
|
||||
for (const t of relatedTickets) {
|
||||
const needsPatch = !t.companySnapshot ||
|
||||
t.companySnapshot.name !== companySnapshot.name ||
|
||||
t.companySnapshot.slug !== companySnapshot.slug ||
|
||||
Boolean(t.companySnapshot.isAvulso ?? false) !== Boolean(companySnapshot.isAvulso ?? false)
|
||||
if (needsPatch) {
|
||||
await ctx.db.patch(t._id, { companySnapshot })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.delete(existing._id)
|
||||
return { removed: true }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -779,3 +779,59 @@ export const syncMachineCompanyReferences = mutation({
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const backfillTicketSnapshots = mutation({
|
||||
args: { tenantId: v.string(), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { tenantId, limit }) => {
|
||||
const tickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
let processed = 0
|
||||
for (const t of tickets) {
|
||||
if (limit && processed >= limit) break
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (!t.requesterSnapshot) {
|
||||
const requester = await ctx.db.get(t.requesterId)
|
||||
if (requester) {
|
||||
patch.requesterSnapshot = {
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl ?? undefined,
|
||||
teams: requester.teams ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.assigneeId && !t.assigneeSnapshot) {
|
||||
const assignee = await ctx.db.get(t.assigneeId)
|
||||
if (assignee) {
|
||||
patch.assigneeSnapshot = {
|
||||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl ?? undefined,
|
||||
teams: assignee.teams ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!t.companySnapshot) {
|
||||
const companyId = t.companyId
|
||||
if (companyId) {
|
||||
const company = await ctx.db.get(companyId)
|
||||
if (company) {
|
||||
patch.companySnapshot = {
|
||||
name: company.name,
|
||||
slug: company.slug,
|
||||
isAvulso: company.isAvulso ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(t._id, patch)
|
||||
}
|
||||
processed += 1
|
||||
}
|
||||
return { processed }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -85,8 +85,31 @@ export default defineSchema({
|
|||
categoryId: v.optional(v.id("ticketCategories")),
|
||||
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
||||
requesterId: v.id("users"),
|
||||
requesterSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
assigneeSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
companySnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
slug: v.optional(v.string()),
|
||||
isAvulso: v.optional(v.boolean()),
|
||||
})
|
||||
),
|
||||
working: v.optional(v.boolean()),
|
||||
slaPolicyId: v.optional(v.id("slaPolicies")),
|
||||
dueAt: v.optional(v.number()), // ms since epoch
|
||||
|
|
@ -117,6 +140,7 @@ export default defineSchema({
|
|||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
||||
.index("by_tenant_reference", ["tenantId", "reference"])
|
||||
.index("by_tenant_requester", ["tenantId", "requesterId"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,30 @@ export const deleteUser = mutation({
|
|||
);
|
||||
}
|
||||
|
||||
// Preserve requester snapshot on tickets where this user is the requester
|
||||
const requesterTickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", userId))
|
||||
.collect();
|
||||
if (requesterTickets.length > 0) {
|
||||
const requesterSnapshot = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl ?? undefined,
|
||||
teams: user.teams ?? undefined,
|
||||
};
|
||||
for (const t of requesterTickets) {
|
||||
const needsPatch = !t.requesterSnapshot ||
|
||||
t.requesterSnapshot.name !== requesterSnapshot.name ||
|
||||
t.requesterSnapshot.email !== requesterSnapshot.email ||
|
||||
t.requesterSnapshot.avatarUrl !== requesterSnapshot.avatarUrl ||
|
||||
JSON.stringify(t.requesterSnapshot.teams ?? []) !== JSON.stringify(requesterSnapshot.teams ?? []);
|
||||
if (needsPatch) {
|
||||
await ctx.db.patch(t._id, { requesterSnapshot });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.delete(userId);
|
||||
return { status: "deleted" };
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue