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 }
|
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)
|
await ctx.db.delete(existing._id)
|
||||||
return { removed: true }
|
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")),
|
categoryId: v.optional(v.id("ticketCategories")),
|
||||||
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
||||||
requesterId: v.id("users"),
|
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")),
|
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")),
|
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()),
|
working: v.optional(v.boolean()),
|
||||||
slaPolicyId: v.optional(v.id("slaPolicies")),
|
slaPolicyId: v.optional(v.id("slaPolicies")),
|
||||||
dueAt: v.optional(v.number()), // ms since epoch
|
dueAt: v.optional(v.number()), // ms since epoch
|
||||||
|
|
@ -117,6 +140,7 @@ export default defineSchema({
|
||||||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||||
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
||||||
.index("by_tenant_reference", ["tenantId", "reference"])
|
.index("by_tenant_reference", ["tenantId", "reference"])
|
||||||
|
.index("by_tenant_requester", ["tenantId", "requesterId"])
|
||||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||||
.index("by_tenant", ["tenantId"]),
|
.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 = {
|
type CommentAuthorFallbackContext = {
|
||||||
ticketId?: Id<"tickets">;
|
ticketId?: Id<"tickets">;
|
||||||
commentId?: Id<"ticketComments">;
|
commentId?: Id<"ticketComments">;
|
||||||
|
|
@ -472,9 +523,20 @@ export const list = query({
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
|
company: company
|
||||||
requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
||||||
assignee: assignee
|
: 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,
|
id: assignee._id,
|
||||||
name: assignee.name,
|
name: assignee.name,
|
||||||
|
|
@ -482,6 +544,7 @@ export const list = query({
|
||||||
avatarUrl: assignee.avatarUrl,
|
avatarUrl: assignee.avatarUrl,
|
||||||
teams: normalizeTeams(assignee.teams),
|
teams: normalizeTeams(assignee.teams),
|
||||||
}
|
}
|
||||||
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
||||||
: null,
|
: null,
|
||||||
slaPolicy: null,
|
slaPolicy: null,
|
||||||
dueAt: t.dueAt ?? null,
|
dueAt: t.dueAt ?? null,
|
||||||
|
|
@ -644,9 +707,20 @@ export const getById = query({
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
|
company: company
|
||||||
requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
||||||
assignee: assignee
|
: 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,
|
id: assignee._id,
|
||||||
name: assignee.name,
|
name: assignee.name,
|
||||||
|
|
@ -654,6 +728,7 @@ export const getById = query({
|
||||||
avatarUrl: assignee.avatarUrl,
|
avatarUrl: assignee.avatarUrl,
|
||||||
teams: normalizeTeams(assignee.teams),
|
teams: normalizeTeams(assignee.teams),
|
||||||
}
|
}
|
||||||
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
||||||
: null,
|
: null,
|
||||||
slaPolicy: null,
|
slaPolicy: null,
|
||||||
dueAt: t.dueAt ?? null,
|
dueAt: t.dueAt ?? null,
|
||||||
|
|
@ -798,6 +873,26 @@ export const create = mutation({
|
||||||
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
|
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", {
|
const id = await ctx.db.insert("tickets", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
|
|
@ -810,8 +905,11 @@ export const create = mutation({
|
||||||
categoryId: args.categoryId,
|
categoryId: args.categoryId,
|
||||||
subcategoryId: args.subcategoryId,
|
subcategoryId: args.subcategoryId,
|
||||||
requesterId: args.requesterId,
|
requesterId: args.requesterId,
|
||||||
|
requesterSnapshot,
|
||||||
assigneeId: initialAssigneeId,
|
assigneeId: initialAssigneeId,
|
||||||
|
assigneeSnapshot,
|
||||||
companyId: requester.companyId ?? undefined,
|
companyId: requester.companyId ?? undefined,
|
||||||
|
companySnapshot,
|
||||||
working: false,
|
working: false,
|
||||||
activeSessionId: undefined,
|
activeSessionId: undefined,
|
||||||
totalWorkedMs: 0,
|
totalWorkedMs: 0,
|
||||||
|
|
@ -1119,7 +1217,13 @@ export const changeAssignee = mutation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
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", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
|
|
@ -1328,7 +1432,13 @@ export const startWork = mutation({
|
||||||
let assigneePatched = false
|
let assigneePatched = false
|
||||||
|
|
||||||
if (!currentAssigneeId) {
|
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
|
ticketDoc.assigneeId = actorId
|
||||||
assigneePatched = true
|
assigneePatched = true
|
||||||
}
|
}
|
||||||
|
|
@ -1542,7 +1652,13 @@ export const playNext = mutation({
|
||||||
const currentStatus = normalizeStatus(chosen.status);
|
const currentStatus = normalizeStatus(chosen.status);
|
||||||
const nextStatus: TicketStatusNormalized =
|
const nextStatus: TicketStatusNormalized =
|
||||||
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
|
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", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: chosen._id,
|
ticketId: chosen._id,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
|
|
@ -1565,8 +1681,15 @@ export const playNext = mutation({
|
||||||
priority: chosen.priority,
|
priority: chosen.priority,
|
||||||
channel: chosen.channel,
|
channel: chosen.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
requester: buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id }),
|
requester: requester
|
||||||
assignee: assignee
|
? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id })
|
||||||
|
: buildRequesterFromSnapshot(
|
||||||
|
chosen.requesterId,
|
||||||
|
chosen.requesterSnapshot ?? undefined,
|
||||||
|
{ ticketId: chosen._id }
|
||||||
|
),
|
||||||
|
assignee: chosen.assigneeId
|
||||||
|
? assignee
|
||||||
? {
|
? {
|
||||||
id: assignee._id,
|
id: assignee._id,
|
||||||
name: assignee.name,
|
name: assignee.name,
|
||||||
|
|
@ -1574,6 +1697,7 @@ export const playNext = mutation({
|
||||||
avatarUrl: assignee.avatarUrl,
|
avatarUrl: assignee.avatarUrl,
|
||||||
teams: normalizeTeams(assignee.teams),
|
teams: normalizeTeams(assignee.teams),
|
||||||
}
|
}
|
||||||
|
: buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined)
|
||||||
: null,
|
: null,
|
||||||
slaPolicy: null,
|
slaPolicy: null,
|
||||||
dueAt: chosen.dueAt ?? 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);
|
await ctx.db.delete(userId);
|
||||||
return { status: "deleted" };
|
return { status: "deleted" };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
71
docs/desktop-handshake-troubleshooting.md
Normal file
71
docs/desktop-handshake-troubleshooting.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Desktop (Tauri) — Handshake, Sessão de Máquina e Antivírus
|
||||||
|
|
||||||
|
Este documento consolida as orientações e diagnósticos sobre o fluxo do agente desktop, handshake na web e possíveis interferências de antivírus.
|
||||||
|
|
||||||
|
## Sintomas observados
|
||||||
|
- Ao clicar em “Registrar máquina”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
|
||||||
|
- Após o registro, ao abrir a UI web: cabeçalho mostra “Cliente / Sem e‑mail definido” e o Portal não permite abrir chamados.
|
||||||
|
- No passado, mesmo quando o app “entrava direto”, o Portal não refletia o colaborador/gestor vinculado (sem assignedUser); receio de repetir o problema.
|
||||||
|
|
||||||
|
## Causas prováveis
|
||||||
|
1) O antivírus interrompe o processo durante o handshake
|
||||||
|
- O app salva token/config, inicia heartbeat e abre `GET /machines/handshake?token=...&redirect=...` para gravar cookies de sessão.
|
||||||
|
- Se o processo cai neste momento, os cookies não são gravados e a UI fica sem sessão “machine”.
|
||||||
|
|
||||||
|
2) Endpoint de contexto sem ler a sessão adequadamente
|
||||||
|
- O Portal preenche o colaborador/gestor via `GET /api/machines/session`.
|
||||||
|
- Em alguns ambientes, é mais estável rodar esse endpoint no runtime `nodejs` para ler `cookies()` do Next sem inconsistências.
|
||||||
|
|
||||||
|
## O que já foi aplicado no projeto
|
||||||
|
- Middleware permite `GET /machines/handshake` sem exigir login (rota pública).
|
||||||
|
- Frontend preenche `machineContext` chamando `GET /api/machines/session` (assignedUserId/email/nome/persona) e usa esse ID ao abrir chamados.
|
||||||
|
- UI oculta “Sair” quando a sessão é de máquina (portal e shell interno).
|
||||||
|
- DevTools habilitado no desktop (F12, Ctrl+Shift+I ou botão direito com Ctrl/Shift).
|
||||||
|
- Desktop salva dados em `C:\\Raven\\data\\machine-agent.json` (ou equivalente ao lado do executável), com fallback para AppData se a pasta do app não permitir escrita.
|
||||||
|
|
||||||
|
## Validação rápida (após “Registrar máquina”)
|
||||||
|
1) No executável, com DevTools:
|
||||||
|
```js
|
||||||
|
fetch('/api/machines/session').then(r => r.status).then(console.log)
|
||||||
|
// Esperado: 200
|
||||||
|
fetch('/api/machines/session').then(r => r.json()).then(console.log)
|
||||||
|
// Deve conter: persona (collaborator|manager), assignedUserEmail/nome/Id
|
||||||
|
```
|
||||||
|
2) Na UI (Portal/Topo):
|
||||||
|
- Mostrar nome/e‑mail do colaborador/gestor (não “Cliente / Sem e‑mail definido”).
|
||||||
|
- Sem botão “Sair” (sessão de máquina).
|
||||||
|
3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`).
|
||||||
|
|
||||||
|
Se `GET /api/machines/session` retornar 403:
|
||||||
|
- Verificar se o antivírus barrou o processo na hora do handshake.
|
||||||
|
- Adicionar exceção para `C:\\Raven\\appsdesktop.exe` e `C:\\Raven\\data\\`.
|
||||||
|
- Opcional: forçar `export const runtime = 'nodejs'` no endpoint de sessão para leitura consistente de cookies (Next `cookies()`).
|
||||||
|
|
||||||
|
## Fluxo atual do desktop
|
||||||
|
- Antes de registrar: tela de onboarding (sem “Abrir sistema”).
|
||||||
|
- Após registrar: salva token/config, inicia heartbeat e mostra as abas com “Abrir sistema” e “Reprovisionar”.
|
||||||
|
- Auto‐abrir: o app já tenta abrir o sistema quando detecta token; pode ser reforçado chamando `openSystem()` ao fim do `register()`.
|
||||||
|
|
||||||
|
## Melhorias opcionais
|
||||||
|
- Auto‑abrir imediatamente após `register()` (reforça o comportamento atual e reduz a janela para interferência do AV).
|
||||||
|
- Diagnóstico no desktop: exibir caminho completo do arquivo de dados e botão “Abrir pasta de dados”.
|
||||||
|
- Forçar `nodejs` no `GET /api/machines/session` para estabilidade total na leitura de cookies.
|
||||||
|
|
||||||
|
## Notas sobre antivírus
|
||||||
|
- Apps Tauri não assinados com certificado de code signing do Windows são frequentemente marcados como “desconhecidos”.
|
||||||
|
- A assinatura Minisign (updater) não substitui o code signing do executável.
|
||||||
|
- Recomendações:
|
||||||
|
- Adicionar exceções para o executável e a pasta de dados.
|
||||||
|
- Avaliar aquisição de um certificado de code signing (EV/OV) para distribuição ampla sem alertas.
|
||||||
|
|
||||||
|
## Checklist quando “não mudou nada” após registro
|
||||||
|
- Confirmar `GET /api/machines/session` (status 200 + JSON contendo assignedUser). Caso 403, tratar AV/endpoint runtime.
|
||||||
|
- Verificar cookies no navegador (DevTools → Application → Cookies): presença da sessão e `machine_ctx`.
|
||||||
|
- Validar que o Portal mostra o colaborador/gestor e permite abrir chamado.
|
||||||
|
- Conferir arquivo de dados:
|
||||||
|
- Preferencial: `C:\\Raven\\data\\machine-agent.json`.
|
||||||
|
- Fallback: AppData local do usuário (buscar por `machine-agent.json`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de máquina, dados persistidos junto ao executável e DevTools habilitado.
|
||||||
33
docs/ticket-snapshots.md
Normal file
33
docs/ticket-snapshots.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
Ticket snapshots e histórico
|
||||||
|
|
||||||
|
Este projeto agora preserva o “lastro” de dados sensíveis em tickets através de snapshots gravados no momento da criação e atualizações chave:
|
||||||
|
|
||||||
|
- requesterSnapshot: nome, e‑mail, avatar e equipes do solicitante no momento da abertura.
|
||||||
|
- assigneeSnapshot: nome, e‑mail, avatar e equipes do responsável atribuído.
|
||||||
|
- companySnapshot: nome, slug e flag isAvulso da empresa associada ao ticket.
|
||||||
|
|
||||||
|
Benefícios
|
||||||
|
|
||||||
|
- Tickets continuam exibindo nome do solicitante/empresa mesmo após exclusões ou renomeações.
|
||||||
|
- Comentários já utilizavam authorSnapshot; a lógica foi mantida e ampliada para tickets.
|
||||||
|
|
||||||
|
Fluxos atualizados
|
||||||
|
|
||||||
|
- Criação de ticket: snapshots do solicitante, responsável inicial (se houver) e da empresa são persistidos.
|
||||||
|
- Reatribuição e início de atendimento: atualizam o assigneeSnapshot.
|
||||||
|
- Exclusão de usuário: preenche requesterSnapshot de todos os tickets onde a pessoa é solicitante, antes da remoção.
|
||||||
|
- Exclusão de empresa: preenche companySnapshot de tickets vinculados, antes da remoção.
|
||||||
|
|
||||||
|
Consultas e hidratação
|
||||||
|
|
||||||
|
- Resolvers de lista e detalhes de tickets passaram a usar os snapshots como fallback quando o documento relacionado não existe mais (sem “Solicitante não encontrado”, salvo ausência total de dados).
|
||||||
|
|
||||||
|
Índices novos
|
||||||
|
|
||||||
|
- tickets.by_tenant_requester: otimiza buscas por histórico de um solicitante.
|
||||||
|
|
||||||
|
Backfill
|
||||||
|
|
||||||
|
- Há uma mutation de migração para preenchimento retroativo de snapshots: convex.migrations.backfillTicketSnapshots.
|
||||||
|
- Execute com um tenant por vez (e opcionalmente um limite) se necessário.
|
||||||
|
|
||||||
|
|
@ -52,12 +52,12 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.5",
|
"next": "15.5.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "18.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.5",
|
"eslint-config-next": "15.5.6",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
||||||
926
pnpm-lock.yaml
generated
926
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,24 +7,39 @@ export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function AdminCompaniesPage() {
|
export default async function AdminCompaniesPage() {
|
||||||
const companiesRaw = await prisma.company.findMany({ orderBy: { name: "asc" } })
|
const companiesRaw = await prisma.company.findMany({
|
||||||
const companies = companiesRaw.map((c) => {
|
orderBy: { name: "asc" },
|
||||||
const extra = c as unknown as { isAvulso?: boolean; contractedHoursPerMonth?: number | null }
|
select: {
|
||||||
return {
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
provisioningCode: true,
|
||||||
|
isAvulso: true,
|
||||||
|
contractedHoursPerMonth: true,
|
||||||
|
cnpj: true,
|
||||||
|
domain: true,
|
||||||
|
phone: true,
|
||||||
|
description: true,
|
||||||
|
address: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const companies = companiesRaw.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
tenantId: c.tenantId,
|
tenantId: c.tenantId,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
provisioningCode: c.provisioningCode ?? null,
|
provisioningCode: c.provisioningCode ?? null,
|
||||||
isAvulso: Boolean(extra.isAvulso ?? false),
|
isAvulso: Boolean(c.isAvulso ?? false),
|
||||||
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
|
contractedHoursPerMonth: c.contractedHoursPerMonth ?? null,
|
||||||
cnpj: c.cnpj ?? null,
|
cnpj: c.cnpj ?? null,
|
||||||
domain: c.domain ?? null,
|
domain: c.domain ?? null,
|
||||||
phone: c.phone ?? null,
|
phone: c.phone ?? null,
|
||||||
description: c.description ?? null,
|
description: c.description ?? null,
|
||||||
address: c.address ?? null,
|
address: c.address ?? null,
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={
|
header={
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tipos do Prisma podem estar desatualizados em relação ao schema nessa máquina/ambiente.
|
const company = await prisma.company.update({ where: { id }, data: updates })
|
||||||
// Atualize o client quando possível; por ora liberamos o shape dinamicamente.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const company = await prisma.company.update({ where: { id }, data: updates as any })
|
|
||||||
|
|
||||||
if (company.provisioningCode) {
|
if (company.provisioningCode) {
|
||||||
const synced = await syncConvexCompany({
|
const synced = await syncConvexCompany({
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export async function POST(request: Request) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId: string = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const persona = payload.accessRole ?? undefined
|
const persona = payload.accessRole ?? undefined
|
||||||
const collaborator = payload.collaborator ?? null
|
const collaborator = payload.collaborator ?? null
|
||||||
|
|
||||||
|
|
@ -146,14 +146,14 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
let assignedUserId: Id<"users"> | undefined
|
let assignedUserId: Id<"users"> | undefined
|
||||||
if (collaborator) {
|
if (collaborator) {
|
||||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
const ensuredUser = await client.mutation(api.users.ensureUser, {
|
||||||
tenantId,
|
tenantId,
|
||||||
email: collaborator.email,
|
email: collaborator.email,
|
||||||
name: collaborator.name ?? collaborator.email,
|
name: collaborator.name ?? collaborator.email,
|
||||||
avatarUrl: undefined,
|
avatarUrl: undefined,
|
||||||
role: persona?.toUpperCase(),
|
role: persona?.toUpperCase(),
|
||||||
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
|
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
|
||||||
})) as { _id?: Id<"users"> } | null
|
})
|
||||||
|
|
||||||
await ensureCollaboratorAccount({
|
await ensureCollaboratorAccount({
|
||||||
email: collaborator.email,
|
email: collaborator.email,
|
||||||
|
|
|
||||||
|
|
@ -648,8 +648,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
const somePeopleSelected = selectedPeopleUsers.length > 0 && !allPeopleSelected
|
const somePeopleSelected = selectedPeopleUsers.length > 0 && !allPeopleSelected
|
||||||
|
|
||||||
const selectedMachineUsers = useMemo(() => filteredMachineUsers.filter((u) => machineSelection.has(u.id)), [filteredMachineUsers, machineSelection])
|
const selectedMachineUsers = useMemo(() => filteredMachineUsers.filter((u) => machineSelection.has(u.id)), [filteredMachineUsers, machineSelection])
|
||||||
const allMachinesSelected = selectedMachineUsers.length > 0 && selectedMachineUsers.length === filteredMachineUsers.length
|
|
||||||
const someMachinesSelected = selectedMachineUsers.length > 0 && !allMachinesSelected
|
|
||||||
|
|
||||||
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
|
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
|
||||||
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
|
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
|
||||||
|
|
|
||||||
|
|
@ -982,12 +982,9 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
api.companies.list,
|
api.companies.list,
|
||||||
companyQueryArgs ?? ("skip" as const)
|
companyQueryArgs ?? ("skip" as const)
|
||||||
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
||||||
// Convex codegen precisa ser atualizado para tipar `machines.listAlerts`.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const machinesApi: any = api
|
|
||||||
const alertsHistory = useQuery(
|
const alertsHistory = useQuery(
|
||||||
machine && machinesApi?.machines?.listAlerts ? machinesApi.machines.listAlerts : "skip",
|
machine ? api.machines.listAlerts : "skip",
|
||||||
machine && machinesApi?.machines?.listAlerts ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const)
|
machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const)
|
||||||
) as MachineAlertEntry[] | undefined
|
) as MachineAlertEntry[] | undefined
|
||||||
const machineAlertsHistory = alertsHistory ?? []
|
const machineAlertsHistory = alertsHistory ?? []
|
||||||
const metadata = machine?.inventory ?? null
|
const metadata = machine?.inventory ?? null
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
Timer,
|
Timer,
|
||||||
MonitorCog,
|
MonitorCog,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
BellRing,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
})
|
})
|
||||||
}, [workSummaryRemote, calibrateServerOffset])
|
}, [workSummaryRemote, calibrateServerOffset])
|
||||||
|
|
||||||
const isPlaying = Boolean(workSummary?.activeSession)
|
const activeSessionId = workSummary?.activeSession?.id ?? null
|
||||||
|
const activeSessionStartedAt = workSummary?.activeSession?.startedAt ?? null
|
||||||
|
const isPlaying = Boolean(activeSessionId)
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
// Guarda um marcador local do início da sessão atual para evitar inflar tempo com
|
// Guarda um marcador local do início da sessão atual para evitar inflar tempo com
|
||||||
// timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local).
|
// timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local).
|
||||||
|
|
@ -427,28 +429,28 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const localStartOriginRef = useRef<SessionStartOrigin>("unknown")
|
const localStartOriginRef = useRef<SessionStartOrigin>("unknown")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workSummary?.activeSession) return
|
if (!activeSessionId) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setNow(Date.now())
|
setNow(Date.now())
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [workSummary?.activeSession?.id])
|
}, [activeSessionId])
|
||||||
|
|
||||||
// Sempre que a sessão ativa (id) mudar, sincroniza o ponteiro local
|
// Sempre que a sessão ativa (id) mudar, sincroniza o ponteiro local
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workSummary?.activeSession) {
|
if (!activeSessionId) {
|
||||||
localStartAtRef.current = 0
|
localStartAtRef.current = 0
|
||||||
localStartOriginRef.current = "unknown"
|
localStartOriginRef.current = "unknown"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { localStart, origin } = reconcileLocalSessionStart({
|
const { localStart, origin } = reconcileLocalSessionStart({
|
||||||
remoteStart: Number(workSummary.activeSession.startedAt) || 0,
|
remoteStart: Number(activeSessionStartedAt) || 0,
|
||||||
localStart: localStartAtRef.current,
|
localStart: localStartAtRef.current,
|
||||||
origin: localStartOriginRef.current,
|
origin: localStartOriginRef.current,
|
||||||
})
|
})
|
||||||
localStartAtRef.current = localStart
|
localStartAtRef.current = localStart
|
||||||
localStartOriginRef.current = origin
|
localStartOriginRef.current = origin
|
||||||
}, [workSummary?.activeSession?.id, workSummary?.activeSession?.startedAt])
|
}, [activeSessionId, activeSessionStartedAt])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pauseDialogOpen) {
|
if (!pauseDialogOpen) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue