fix: harden machine session fallback and clean lint

This commit is contained in:
Esdras Renan 2025-10-19 03:16:50 -03:00
parent 2607ca5ce3
commit 846e575637
6 changed files with 60 additions and 32 deletions

View file

@ -106,6 +106,53 @@ function normalizeTeams(teams?: string[] | null): string[] {
return teams.map((team) => renameQueueString(team) ?? team); return teams.map((team) => renameQueueString(team) ?? team);
} }
type RequesterFallbackContext = {
ticketId?: Id<"tickets">;
fallbackName?: string | null;
fallbackEmail?: string | null;
};
function buildRequesterSummary(
requester: Doc<"users"> | null,
requesterId: Id<"users">,
context?: RequesterFallbackContext,
) {
if (requester) {
return {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: normalizeTeams(requester.teams),
};
}
const idString = String(requesterId);
const fallbackName =
typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0
? context.fallbackName.trim()
: "Solicitante não encontrado";
const fallbackEmailCandidate =
typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@")
? context.fallbackEmail
: null;
const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`;
if (process.env.NODE_ENV !== "test") {
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
console.warn(
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
);
}
return {
id: requesterId,
name: fallbackName,
email: fallbackEmail,
teams: [],
};
}
type CustomFieldInput = { type CustomFieldInput = {
fieldId: Id<"ticketFields">; fieldId: Id<"ticketFields">;
value: unknown; value: unknown;
@ -348,13 +395,7 @@ export const list = query({
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 ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
requester: requester && { requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: normalizeTeams(requester.teams),
},
assignee: assignee assignee: assignee
? { ? {
id: assignee._id, id: assignee._id,
@ -526,13 +567,7 @@ export const getById = query({
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 ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null,
requester: requester && { requester: buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }),
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: normalizeTeams(requester.teams),
},
assignee: assignee assignee: assignee
? { ? {
id: assignee._id, id: assignee._id,
@ -1434,13 +1469,7 @@ export const playNext = mutation({
priority: chosen.priority, priority: chosen.priority,
channel: chosen.channel, channel: chosen.channel,
queue: queueName, queue: queueName,
requester: requester && { requester: buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id }),
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: normalizeTeams(requester.teams),
},
assignee: assignee assignee: assignee
? { ? {
id: assignee._id, id: assignee._id,

View file

@ -2,9 +2,6 @@ import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { requireAdmin } from "./rbac"; import { requireAdmin } from "./rbac";
// All roles that have staff-level access in some areas. Do NOT include COLLABORATOR here
// to avoid leaking collaborators into staff pickers such as "responsável".
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
export const ensureUser = mutation({ export const ensureUser = mutation({

View file

@ -994,16 +994,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const metrics = machine?.metrics ?? null const metrics = machine?.metrics ?? null
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics]) const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
// Live refresh the relative time label every second when we have a capture timestamp // Live refresh the relative time label every second when we have a capture timestamp
const [relativeTick, setRelativeTick] = useState(0) const [, setRelativeTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!metricsCapturedAt) return if (!metricsCapturedAt) return
const id = setInterval(() => setRelativeTick((t) => t + 1), 1000) const id = setInterval(() => setRelativeTick((t) => t + 1), 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, [metricsCapturedAt]) }, [metricsCapturedAt])
const lastUpdateRelative = useMemo( const lastUpdateRelative = metricsCapturedAt ? formatRelativeTime(metricsCapturedAt) : null
() => (metricsCapturedAt ? formatRelativeTime(metricsCapturedAt) : null),
[metricsCapturedAt, relativeTick]
)
const hardware = metadata?.hardware const hardware = metadata?.hardware
const network = metadata?.network ?? null const network = metadata?.network ?? null
const networkInterfaces = Array.isArray(network) ? network : null const networkInterfaces = Array.isArray(network) ? network : null

View file

@ -635,7 +635,7 @@ function PortalCommentAttachmentCard({
window.open(target, "_blank", "noopener,noreferrer") window.open(target, "_blank", "noopener,noreferrer")
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId }) toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
} }
}, [attachment.name, ensureUrl]) }, [attachment.id, attachment.name, ensureUrl])
const resolvedUrl = url const resolvedUrl = url

View file

@ -650,7 +650,7 @@ function CommentAttachmentCard({
window.open(target, "_blank", "noopener,noreferrer") window.open(target, "_blank", "noopener,noreferrer")
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId }) toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
} }
}, [attachment.name, ensureUrl, url]) }, [attachment.id, attachment.name, ensureUrl, url])
const name = attachment.name ?? "" const name = attachment.name ?? ""
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false

View file

@ -109,6 +109,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// carregar o contexto — se a API responder 200, assumimos que há sessão válida // carregar o contexto — se a API responder 200, assumimos que há sessão válida
// do lado do servidor e populamos o contexto para o restante do app. // do lado do servidor e populamos o contexto para o restante do app.
useEffect(() => { useEffect(() => {
if (isPending) {
setMachineContextLoading(true)
return
}
const shouldFetch = Boolean(session?.user?.role === "machine") || !session?.user const shouldFetch = Boolean(session?.user?.role === "machine") || !session?.user
if (!shouldFetch) { if (!shouldFetch) {
setMachineContext(null) setMachineContext(null)
@ -215,7 +220,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [session?.user]) }, [session?.user, isPending])
// Poll machine session periodically to reflect admin changes (e.g., deactivation) // Poll machine session periodically to reflect admin changes (e.g., deactivation)
useEffect(() => { useEffect(() => {