// CI touch: enable server-side assignee filtering and trigger redeploy import { mutation, query } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { api } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc, type TableNames } from "./_generated/dataModel"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; import { runTicketAutomationsForEvent } from "./automations"; import { OPTIONAL_ADMISSION_FIELD_KEYS, TICKET_FORM_CONFIG, TICKET_FORM_DEFAULT_FIELDS, type TicketFormFieldSeed, } from "./ticketForms.config"; import { ensureTicketFormTemplatesForTenant, getTemplateByKey, normalizeFormTemplateKey, } from "./ticketFormTemplates"; import { applyChecklistTemplateToItems, checklistBlocksResolution, normalizeChecklistText, type TicketChecklistItem, } from "./ticketChecklist"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); const LUNCH_BREAK_REASON = "LUNCH_BREAK"; const LUNCH_BREAK_NOTE = "Pausa automática do intervalo de almoço"; const LUNCH_BREAK_PAUSE_LABEL = "Intervalo de almoço"; const LUNCH_BREAK_TIMEZONE = "America/Sao_Paulo"; const LUNCH_BREAK_HOUR = 12; const PAUSE_REASON_LABELS: Record = { NO_CONTACT: "Falta de contato", WAITING_THIRD_PARTY: "Aguardando terceiro", IN_PROCEDURE: "Em procedimento", [LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL, }; const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_LABELS: Record = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausado", RESOLVED: "Resolvido", }; const LEGACY_STATUS_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", CLOSED: "RESOLVED", }; function normalizePriorityFilter(input: string | string[] | null | undefined): string[] { if (!input) return []; const list = Array.isArray(input) ? input : [input]; const set = new Set(); for (const entry of list) { if (typeof entry !== "string") continue; const normalized = entry.trim().toUpperCase(); if (!normalized) continue; set.add(normalized); } return Array.from(set); } const missingRequesterLogCache = new Set(); const missingCommentAuthorLogCache = new Set(); // Character limits (generous but bounded) const MAX_SUMMARY_CHARS = 600; const MAX_COMMENT_CHARS = 20000; const DEFAULT_REOPEN_DAYS = 7; const MAX_REOPEN_DAYS = 14; const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]; const VISIT_STATUSES = new Set(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"]); const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]); type AnyCtx = QueryCtx | MutationCtx; // Tipos para eventos de chat type ChatEventPayload = { sessionId?: string; agentId?: string; agentName?: string; machineHostname?: string; durationMs?: number; startedAt?: number; endedAt?: number; autoEnded?: boolean; reason?: string; }; type TimelineEvent = { _id: Id<"ticketEvents">; type: string; payload?: unknown; createdAt: number; }; /** * Consolida eventos de chat ao vivo na timeline. * Em vez de mostrar "Chat iniciado" e "Chat finalizado" separadamente, * mostra um unico evento "LIVE_CHAT_SUMMARY" com a duracao total. */ function consolidateChatEventsInTimeline(events: TimelineEvent[]): TimelineEvent[] { const chatStartEvents: TimelineEvent[] = []; const chatEndEvents: TimelineEvent[] = []; const otherEvents: TimelineEvent[] = []; // Separar eventos de chat dos demais for (const event of events) { if (event.type === "LIVE_CHAT_STARTED") { chatStartEvents.push(event); } else if (event.type === "LIVE_CHAT_ENDED") { chatEndEvents.push(event); } else { otherEvents.push(event); } } // Se nao houver eventos de chat, retornar como esta if (chatStartEvents.length === 0 && chatEndEvents.length === 0) { return events; } // Calcular duracao total de todas as sessoes encerradas let totalDurationMs = 0; const sessionIds = new Set(); let lastAgentName = ""; let mostRecentActivity = 0; let hasActiveSession = false; for (const endEvent of chatEndEvents) { const payload = endEvent.payload as ChatEventPayload | undefined; if (payload?.durationMs) { totalDurationMs += payload.durationMs; } if (payload?.sessionId) { sessionIds.add(payload.sessionId); } if (payload?.agentName) { lastAgentName = payload.agentName; } if (endEvent.createdAt > mostRecentActivity) { mostRecentActivity = endEvent.createdAt; } } // Verificar sessoes iniciadas mas nao encerradas (ativas) const endedSessionIds = new Set( chatEndEvents .map((e) => (e.payload as ChatEventPayload | undefined)?.sessionId) .filter(Boolean) ); for (const startEvent of chatStartEvents) { const payload = startEvent.payload as ChatEventPayload | undefined; const sessionId = payload?.sessionId; if (sessionId && !endedSessionIds.has(sessionId)) { hasActiveSession = true; sessionIds.add(sessionId); } if (payload?.agentName && !lastAgentName) { lastAgentName = payload.agentName; } if (startEvent.createdAt > mostRecentActivity) { mostRecentActivity = startEvent.createdAt; } } // Se so tem sessao iniciada sem encerrar, mostrar o evento de inicio if (chatEndEvents.length === 0 && chatStartEvents.length > 0) { // Retornar o evento de inicio mais recente const latestStart = chatStartEvents.reduce((a, b) => a.createdAt > b.createdAt ? a : b ); return [...otherEvents, latestStart]; } // Criar evento consolidado const sessionCount = sessionIds.size || chatEndEvents.length; const consolidatedEvent: TimelineEvent = { _id: chatEndEvents[0]._id, // Usar ID do primeiro evento para manter referencia type: "LIVE_CHAT_SUMMARY", payload: { sessionCount, totalDurationMs, agentName: lastAgentName || "Agente", hasActiveSession, }, createdAt: mostRecentActivity, }; return [...otherEvents, consolidatedEvent]; } type TemplateSummary = { key: string; label: string; description: string; defaultEnabled: boolean; }; type TicketFieldScopeMap = Map[]>; function plainTextLength(html: string): number { try { const text = String(html) .replace(/<[^>]*>/g, "") // strip tags .replace(/ /g, " ") .trim(); return text.length; } catch { return String(html ?? "").length; } } const SLA_DEFAULT_ALERT_THRESHOLD = 0.8; const BUSINESS_DAY_START_HOUR = 8; const BUSINESS_DAY_END_HOUR = 18; type SlaTimeMode = "business" | "calendar"; type TicketSlaSnapshot = { categoryId?: Id<"ticketCategories">; categoryName?: string; priority: string; responseTargetMinutes?: number; responseMode: SlaTimeMode; solutionTargetMinutes?: number; solutionMode: SlaTimeMode; alertThreshold: number; pauseStatuses: TicketStatusNormalized[]; }; type SlaStatusValue = "pending" | "met" | "breached" | "n/a"; function normalizeSlaMode(input?: string | null): SlaTimeMode { if (!input) return "calendar"; return input.toLowerCase() === "business" ? "business" : "calendar"; } function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] { if (!Array.isArray(statuses)) { return ["PAUSED"]; } const set = new Set(); for (const value of statuses) { if (typeof value !== "string") continue; const normalized = normalizeStatus(value); set.add(normalized); } if (set.size === 0) { set.add("PAUSED"); } return Array.from(set); } async function resolveTicketSlaSnapshot( ctx: AnyCtx, tenantId: string, category: Doc<"ticketCategories"> | null, priority: string ): Promise { if (!category) { return null; } const normalizedPriority = priority.trim().toUpperCase(); const rule = (await ctx.db .query("categorySlaSettings") .withIndex("by_tenant_category_priority", (q) => q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) ) .first()) ?? (await ctx.db .query("categorySlaSettings") .withIndex("by_tenant_category_priority", (q) => q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") ) .first()); if (!rule) { return null; } return { categoryId: category._id, categoryName: category.name, priority: normalizedPriority, responseTargetMinutes: rule.responseTargetMinutes ?? undefined, responseMode: normalizeSlaMode(rule.responseMode), solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined, solutionMode: normalizeSlaMode(rule.solutionMode), alertThreshold: typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold) ? rule.alertThreshold : SLA_DEFAULT_ALERT_THRESHOLD, pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses), }; } function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) { return { responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode), solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode), }; } function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null { if (minutes === null || minutes === undefined || minutes <= 0) { return null; } if (mode === "calendar") { return startAt + minutes * 60000; } let remaining = minutes; let cursor = alignToBusinessStart(new Date(startAt)); while (remaining > 0) { if (!isBusinessDay(cursor)) { cursor = advanceToNextBusinessStart(cursor); continue; } const endOfDay = new Date(cursor); endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0); const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000; if (minutesAvailable >= remaining) { cursor = new Date(cursor.getTime() + remaining * 60000); remaining = 0; } else { remaining -= minutesAvailable; cursor = advanceToNextBusinessStart(endOfDay); } } return cursor.getTime(); } function alignToBusinessStart(date: Date): Date { let result = new Date(date); if (!isBusinessDay(result)) { return advanceToNextBusinessStart(result); } if (result.getHours() >= BUSINESS_DAY_END_HOUR) { return advanceToNextBusinessStart(result); } if (result.getHours() < BUSINESS_DAY_START_HOUR) { result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0); } return result; } function advanceToNextBusinessStart(date: Date): Date { const next = new Date(date); next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0); next.setDate(next.getDate() + 1); while (!isBusinessDay(next)) { next.setDate(next.getDate() + 1); } return next; } function isBusinessDay(date: Date) { const day = date.getDay(); return day !== 0 && day !== 6; } function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) { if (!snapshot) return {}; const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now); return { slaSnapshot: snapshot, slaResponseDueAt: responseDueAt ?? undefined, slaSolutionDueAt: solutionDueAt ?? undefined, slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue), slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue), dueAt: solutionDueAt ?? undefined, }; } function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) { const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined; if (!snapshot) return {}; const pauseSet = new Set(snapshot.pauseStatuses); const currentlyPaused = typeof ticketDoc.slaPausedAt === "number"; if (pauseSet.has(nextStatus)) { if (currentlyPaused) { return {}; } return { slaPausedAt: now, slaPausedBy: nextStatus, }; } if (currentlyPaused) { const pauseStart = ticketDoc.slaPausedAt ?? now; const delta = Math.max(0, now - pauseStart); const patch: Record = { slaPausedAt: undefined, slaPausedBy: undefined, slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta, }; if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") { patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta; } if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") { patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta; patch.dueAt = ticketDoc.slaSolutionDueAt + delta; } return patch; } return {}; } function getHourMinuteInTimezone(date: number, timeZone: string): { hour: number; minute: number } { const formatter = new Intl.DateTimeFormat("en-CA", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false, }) const parts = formatter.formatToParts(new Date(date)) const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0") const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0") return { hour, minute } } function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record): Doc<"tickets"> { const merged = { ...ticketDoc } as Record; for (const [key, value] of Object.entries(patch)) { if (value === undefined) { delete merged[key]; } else { merged[key] = value; } } return merged as Doc<"tickets">; } async function pauseSessionForLunch(ctx: MutationCtx, ticket: Doc<"tickets">, session: Doc<"ticketWorkSessions">) { const now = Date.now() const durationMs = Math.max(0, now - session.startedAt) const slaPatch = buildSlaStatusPatch(ticket, "PAUSED", now) await ctx.db.patch(session._id, { stoppedAt: now, durationMs, pauseReason: LUNCH_BREAK_REASON, pauseNote: LUNCH_BREAK_NOTE, }) await ctx.db.patch(ticket._id, { working: false, activeSessionId: undefined, status: "PAUSED", totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, internalWorkedMs: (ticket.internalWorkedMs ?? 0) + durationMs, updatedAt: now, ...slaPatch, }) await ctx.db.insert("ticketEvents", { ticketId: ticket._id, type: "WORK_PAUSED", payload: { actorId: null, actorName: "Sistema", actorAvatar: null, sessionId: session._id, sessionDurationMs: durationMs, workType: (session.workType ?? "INTERNAL").toUpperCase(), pauseReason: LUNCH_BREAK_REASON, pauseReasonLabel: PAUSE_REASON_LABELS[LUNCH_BREAK_REASON], pauseNote: LUNCH_BREAK_NOTE, source: "lunch-break-cron", }, createdAt: now, }) } function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) { if (ticketDoc.firstResponseAt) { return {}; } if (!ticketDoc.slaResponseDueAt) { return { firstResponseAt: now, slaResponseStatus: "n/a", }; } const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached"; return { firstResponseAt: now, slaResponseStatus: status, }; } function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) { if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") { return {}; } if (!ticketDoc.slaSolutionDueAt) { return { slaSolutionStatus: "n/a" }; } const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached"; return { slaSolutionStatus: status, }; } function resolveFormTemplateLabel( templateKey: string | null | undefined, storedLabel: string | null | undefined ): string | null { if (storedLabel && storedLabel.trim().length > 0) { return storedLabel.trim(); } const normalizedKey = templateKey?.trim(); if (!normalizedKey) { return null; } const fallback = TICKET_FORM_CONFIG.find((entry) => entry.key === normalizedKey); return fallback ? fallback.label : null; } function escapeHtml(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function resolveReopenWindowDays(input?: number | null): number { if (typeof input !== "number" || !Number.isFinite(input)) { return DEFAULT_REOPEN_DAYS; } const rounded = Math.round(input); if (rounded < 1) return 1; if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS; return rounded; } function computeReopenDeadline(now: number, windowDays: number): number { return now + windowDays * 24 * 60 * 60 * 1000; } function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null { if (typeof ticket.reopenDeadline === "number") { return ticket.reopenDeadline; } if (typeof ticket.closedAt === "number") { return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000; } if (typeof ticket.resolvedAt === "number") { return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000; } return null; } function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean { const deadline = inferExistingReopenDeadline(ticket); if (!deadline) { return true; } return now <= deadline; } function findLatestSetting(entries: T[], predicate: (entry: T) => boolean): T | null { let latest: T | null = null; for (const entry of entries) { if (!predicate(entry)) continue; if (!latest || entry.updatedAt > latest.updatedAt) { latest = entry; } } return latest; } function resolveFormEnabled( template: string, baseEnabled: boolean, settings: Doc<"ticketFormSettings">[], context: { companyId?: Id<"companies"> | null; userId: Id<"users"> } ): boolean { const scoped = settings.filter((setting) => setting.template === template) if (scoped.length === 0) { return baseEnabled } const userSetting = findLatestSetting(scoped, (setting) => { if (setting.scope !== "user") { return false } if (!setting.userId) { return false } return String(setting.userId) === String(context.userId) }) if (userSetting) { return userSetting.enabled ?? baseEnabled } const companyId = context.companyId ? String(context.companyId) : null if (companyId) { const companySetting = findLatestSetting(scoped, (setting) => { if (setting.scope !== "company") { return false } if (!setting.companyId) { return false } return String(setting.companyId) === companyId }) if (companySetting) { return companySetting.enabled ?? baseEnabled } } const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant") if (tenantSetting) { return tenantSetting.enabled ?? baseEnabled } return baseEnabled } async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise { const templates = await ctx.db .query("ticketFormTemplates") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(100); if (!templates.length) { return TICKET_FORM_CONFIG.map((template) => ({ key: template.key, label: template.label, description: template.description, defaultEnabled: template.defaultEnabled, })); } return templates .filter((tpl) => tpl.isArchived !== true) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) .map((tpl) => ({ key: tpl.key, label: tpl.label, description: tpl.description ?? "", defaultEnabled: tpl.defaultEnabled ?? true, })); } async function fetchTicketFieldsByScopes( ctx: QueryCtx, tenantId: string, scopes: string[], companyId: Id<"companies"> | null ): Promise { const scopeLookup = new Map(); scopes.forEach((scope) => { const trimmed = scope?.trim(); if (!trimmed) { return; } const normalized = trimmed.toLowerCase(); if (!scopeLookup.has(normalized)) { scopeLookup.set(normalized, trimmed); } }); if (!scopeLookup.size) { return new Map(); } const companyIdStr = companyId ? String(companyId) : null; const result: TicketFieldScopeMap = new Map(); const allFields = await ctx.db .query("ticketFields") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(100); const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => { const originalKey = scopeLookup.get(scopeKey); if (!originalKey) { return; } const current = result.get(originalKey); if (current) { current.push(field); } else { result.set(originalKey, [field]); } }; const normalizedScopeSet = new Set(scopeLookup.keys()); for (const field of allFields) { const rawScope = field.scope?.trim(); const normalizedFieldScope = rawScope && rawScope.length > 0 ? rawScope.toLowerCase() : "all"; const fieldCompanyId = field.companyId ? String(field.companyId) : null; if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) { continue; } if (normalizedFieldScope === "all") { normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field)); continue; } if (normalizedFieldScope === "default") { if (normalizedScopeSet.has("default")) { addFieldToScope("default", field); } continue; } if (!normalizedScopeSet.has(normalizedFieldScope)) { continue; } addFieldToScope(normalizedFieldScope, field); } return result; } async function fetchViewerScopedFormSettings( ctx: QueryCtx, tenantId: string, templateKeys: string[], viewerId: Id<"users">, viewerCompanyId: Id<"companies"> | null ): Promise[]>> { const uniqueTemplates = Array.from(new Set(templateKeys)); if (uniqueTemplates.length === 0) { return new Map(); } const keySet = new Set(uniqueTemplates); const viewerIdStr = String(viewerId); const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null; const scopedMap = new Map[]>(); const allSettings = await ctx.db .query("ticketFormSettings") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(100); for (const setting of allSettings) { if (!keySet.has(setting.template)) { continue; } if (setting.scope === "company") { if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) { continue; } } else if (setting.scope === "user") { if (!setting.userId || String(setting.userId) !== viewerIdStr) { continue; } } else if (setting.scope !== "tenant") { continue; } if (scopedMap.has(setting.template)) { scopedMap.get(setting.template)!.push(setting); } else { scopedMap.set(setting.template, [setting]); } } return scopedMap; } function normalizeDateOnlyValue(value: unknown): string | null { if (value === null || value === undefined) { return null; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; if (DATE_ONLY_REGEX.test(trimmed)) { return trimmed; } const parsed = new Date(trimmed); if (Number.isNaN(parsed.getTime())) { return null; } return parsed.toISOString().slice(0, 10); } const date = value instanceof Date ? value : typeof value === "number" ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) { return null; } return date.toISOString().slice(0, 10); } async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) { await ensureTicketFormTemplatesForTenant(ctx, tenantId); const now = Date.now(); for (const template of TICKET_FORM_CONFIG) { const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? []; if (!defaults.length) { continue; } const existing = await ctx.db .query("ticketFields") .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key)) .take(100); if (template.key === "admissao") { for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) { const field = existing.find((f) => f.key === key); if (!field) continue; const updates: Partial> = {}; if (field.required) { updates.required = false; } if (key === "colaborador_patrimonio") { const desiredLabel = "Patrimônio do computador (se houver)"; if ((field.label ?? "").trim() !== desiredLabel) { updates.label = desiredLabel; } } if (Object.keys(updates).length) { await ctx.db.patch(field._id, { ...updates, updatedAt: now, }); } } } const existingKeys = new Set(existing.map((field) => field.key)); let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0); for (const field of defaults) { if (existingKeys.has(field.key)) { // Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima continue; } order += 1; await ctx.db.insert("ticketFields", { tenantId, key: field.key, label: field.label, description: field.description ?? "", type: field.type, required: Boolean(field.required), options: field.options?.map((option) => ({ value: option.value, label: option.label, })), scope: template.key, companyId: field.companyId ?? undefined, order, createdAt: now, updatedAt: now, }); } } } function truncateSubject(subject: string) { if (subject.length <= 60) return subject return `${subject.slice(0, 57)}…` } const TICKET_MENTION_ANCHOR_CLASSES = "ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200" const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900" const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400" const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700" const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full" const TICKET_MENTION_STATUS_TONE: Record = { PENDING: "bg-amber-400", AWAITING_ATTENDANCE: "bg-sky-500", PAUSED: "bg-violet-500", RESOLVED: "bg-emerald-500", } function buildTicketMentionAnchor(ticket: Doc<"tickets">): string { const reference = ticket.reference const subject = escapeHtml(ticket.subject ?? "") const truncated = truncateSubject(subject) const status = (ticket.status ?? "PENDING").toString().toUpperCase() const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase() const normalizedStatus = normalizeStatus(status) const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400" const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}` return `#${reference}${truncated}` } function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) { if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true if (viewerRole === "COLLABORATOR") { return String(ticket.requesterId) === String(viewerId) } if (viewerRole === "MANAGER") { // Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante return String(ticket.requesterId) === String(viewerId) } return false } async function normalizeTicketMentions( ctx: MutationCtx, html: string, viewer: { user: Doc<"users">; role: string }, tenantId: string, ): Promise { if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) { return html } const mentionPattern = /]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi const matches = Array.from(html.matchAll(mentionPattern)) if (!matches.length) { return html } let output = html const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi for (const match of matches) { const full = match[0] attributePattern.lastIndex = 0 const attributes: Record = {} let attrMatch: RegExpExecArray | null while ((attrMatch = attributePattern.exec(full)) !== null) { attributes[attrMatch[1]] = attrMatch[2] } let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null if (!ticketIdRaw && attributes.href) { const hrefPath = attributes.href.split("?")[0] const segments = hrefPath.split("/").filter(Boolean) ticketIdRaw = segments.pop() ?? null } let replacement = "" if (ticketIdRaw) { const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">) if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) { replacement = buildTicketMentionAnchor(ticket) } else { const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() replacement = escapeHtml(inner || `#${ticketIdRaw}`) } } else { replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()) } output = output.replace(full, replacement) } return output } export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } function formatWorkDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) { return "0m"; } const totalMinutes = Math.round(ms / 60000); const hours = Math.floor(totalMinutes / 60); const minutes = Math.abs(totalMinutes % 60); const parts: string[] = []; if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); if (parts.length === 0) { return "0m"; } return parts.join(" "); } function formatWorkDelta(deltaMs: number): string { if (deltaMs === 0) return "0m"; const sign = deltaMs > 0 ? "+" : "-"; const absolute = formatWorkDuration(Math.abs(deltaMs)); return `${sign}${absolute}`; } type AgentWorkTotals = { agentId: Id<"users">; agentName: string | null; agentEmail: string | null; avatarUrl: string | null; totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number; }; async function computeAgentWorkTotals( ctx: MutationCtx | QueryCtx, ticketId: Id<"tickets">, referenceNow: number, ): Promise { const sessions = await ctx.db .query("ticketWorkSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .take(50); if (!sessions.length) { return []; } const totals = new Map< string, { totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number } >(); for (const session of sessions) { const baseDuration = typeof session.durationMs === "number" ? session.durationMs : typeof session.stoppedAt === "number" ? session.stoppedAt - session.startedAt : referenceNow - session.startedAt; const durationMs = Math.max(0, baseDuration); if (durationMs <= 0) continue; const key = session.agentId as string; const bucket = totals.get(key) ?? { totalWorkedMs: 0, internalWorkedMs: 0, externalWorkedMs: 0, }; bucket.totalWorkedMs += durationMs; const workType = (session.workType ?? "INTERNAL").toUpperCase(); if (workType === "EXTERNAL") { bucket.externalWorkedMs += durationMs; } else { bucket.internalWorkedMs += durationMs; } totals.set(key, bucket); } if (totals.size === 0) { return []; } const agentIds = Array.from(totals.keys()); const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">))); return agentIds .map((agentId, index) => { const bucket = totals.get(agentId)!; const agentDoc = agents[index] as Doc<"users"> | null; return { agentId: agentId as Id<"users">, agentName: agentDoc?.name ?? null, agentEmail: agentDoc?.email ?? null, avatarUrl: agentDoc?.avatarUrl ?? null, totalWorkedMs: bucket.totalWorkedMs, internalWorkedMs: bucket.internalWorkedMs, externalWorkedMs: bucket.externalWorkedMs, }; }) .sort((a, b) => b.totalWorkedMs - a.totalWorkedMs); } async function ensureManagerTicketAccess( ctx: MutationCtx | QueryCtx, manager: Doc<"users">, ticket: Doc<"tickets">, ): Promise | null> { if (!manager.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } if (ticket.companyId && ticket.companyId === manager.companyId) { return null } const requester = await ctx.db.get(ticket.requesterId) if (!requester || requester.companyId !== manager.companyId) { throw new ConvexError("Acesso restrito à empresa") } return requester as Doc<"users"> } async function requireTicketStaff( ctx: MutationCtx | QueryCtx, actorId: Id<"users">, ticket: Doc<"tickets"> ) { const viewer = await requireStaff(ctx, actorId, ticket.tenantId) if (viewer.role === "MANAGER") { await ensureManagerTicketAccess(ctx, viewer.user, ticket) } return viewer } type TicketChatParticipant = { user: Doc<"users">; role: string | null; kind: "staff" | "manager" | "requester"; }; async function requireTicketChatParticipant( ctx: MutationCtx | QueryCtx, actorId: Id<"users">, ticket: Doc<"tickets"> ): Promise { const viewer = await requireUser(ctx, actorId, ticket.tenantId); const normalizedRole = viewer.role ?? ""; if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") { return { user: viewer.user, role: normalizedRole, kind: "staff" }; } if (normalizedRole === "MANAGER") { await ensureManagerTicketAccess(ctx, viewer.user, ticket); return { user: viewer.user, role: normalizedRole, kind: "manager" }; } if (normalizedRole === "COLLABORATOR") { // Verificar se e o solicitante if (String(ticket.requesterId) === String(viewer.user._id)) { return { user: viewer.user, role: normalizedRole, kind: "requester" }; } // Verificar se esta vinculado a maquina do ticket if (ticket.machineId) { const machine = await ctx.db.get(ticket.machineId); if (machine) { const isLinkedToMachine = machine.assignedUserId?.toString() === viewer.user._id.toString() || machine.linkedUserIds?.some((id) => id.toString() === viewer.user._id.toString()); if (isLinkedToMachine) { return { user: viewer.user, role: normalizedRole, kind: "requester" }; } } } throw new ConvexError("Apenas o solicitante pode conversar neste chamado"); } throw new ConvexError("Usuário não possui acesso ao chat deste chamado"); } const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", chamados: "Chamados", "Suporte N2": "Laboratório", "suporte-n2": "Laboratório", laboratorio: "Laboratório", Laboratorio: "Laboratório", visitas: "Visitas", }; function renameQueueString(value?: string | null): string | null { if (!value) return value ?? null; const direct = QUEUE_RENAME_LOOKUP[value]; if (direct) return direct; const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; } function normalizeQueueName(queue?: Doc<"queues"> | null): string | null { if (!queue) return null; const normalized = renameQueueString(queue.name); if (normalized) { return normalized; } if (queue.slug) { const fromSlug = renameQueueString(queue.slug); if (fromSlug) return fromSlug; } return queue.name; } function normalizeTeams(teams?: string[] | null): string[] { if (!teams) return []; 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)})` : ""; const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`; if (!missingRequesterLogCache.has(cacheKey)) { missingRequesterLogCache.add(cacheKey); console.warn( `[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`, ); } } return { id: requesterId, name: fallbackName, email: fallbackEmail, teams: [], }; } 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">; }; 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 = { fieldId: Id<"ticketFields">; value: unknown; }; type NormalizedCustomField = { fieldId: Id<"ticketFields">; fieldKey: string; label: string; type: string; value: unknown; displayValue?: string; }; function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } { switch (field.type) { case "text": return { value: String(raw).trim() }; case "number": { const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", ".")); if (!Number.isFinite(value)) { throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`); } return { value }; } case "date": { const normalized = normalizeDateOnlyValue(raw); if (!normalized) { throw new ConvexError(`Data inválida para o campo ${field.label}`); } return { value: normalized }; } case "boolean": { if (typeof raw === "boolean") { return { value: raw }; } if (typeof raw === "string") { const normalized = raw.toLowerCase(); if (normalized === "true" || normalized === "1") return { value: true }; if (normalized === "false" || normalized === "0") return { value: false }; } throw new ConvexError(`Valor inválido para o campo ${field.label}`); } case "select": { if (!field.options || field.options.length === 0) { throw new ConvexError(`Campo ${field.label} sem opções configuradas`); } const value = String(raw); const option = field.options.find((opt) => opt.value === value); if (!option) { throw new ConvexError(`Seleção inválida para o campo ${field.label}`); } return { value: option.value, displayValue: option.label ?? option.value }; } default: return { value: raw }; } } async function normalizeCustomFieldValues( ctx: Pick, tenantId: string, inputs: CustomFieldInput[] | undefined, scope?: string | null ): Promise { const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null; const definitions = await ctx.db .query("ticketFields") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(100); const scopedDefinitions = definitions.filter((definition) => { const fieldScope = (definition.scope ?? "all").toLowerCase(); if (fieldScope === "all" || fieldScope.length === 0) { return true; } if (!normalizedScope) { return false; } return fieldScope === normalizedScope; }); if (!scopedDefinitions.length) { if (inputs && inputs.length > 0) { throw new ConvexError("Campos personalizados não configurados para este formulário"); } return []; } const provided = new Map, unknown>(); for (const entry of inputs ?? []) { provided.set(entry.fieldId, entry.value); } const normalized: NormalizedCustomField[] = []; for (const definition of scopedDefinitions.sort((a, b) => a.order - b.order)) { const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined; const isMissing = raw === undefined || raw === null || (typeof raw === "string" && raw.trim().length === 0); if (isMissing) { if (definition.required) { throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`); } continue; } const { value, displayValue } = coerceCustomFieldValue(definition, raw); normalized.push({ fieldId: definition._id, fieldKey: definition.key, label: definition.label, type: definition.type, value, displayValue, }); } return normalized; } function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) { if (!entries || entries.length === 0) return {}; return entries.reduce>((acc, entry) => { let value: unknown = entry.value; if (entry.type === "date") { value = normalizeDateOnlyValue(entry.value) ?? entry.value; } acc[entry.fieldKey] = { label: entry.label, type: entry.type, value, displayValue: entry.displayValue, }; return acc; }, {}); } type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined; function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean { return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b); } function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string { if (!entry) return "__undefined__"; return JSON.stringify({ value: normalizeEntryValue(entry.value), displayValue: entry.displayValue ?? null, }); } function normalizeEntryValue(value: unknown): unknown { if (value === undefined || value === null) return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "number" && Number.isNaN(value)) return "__nan__"; if (Array.isArray(value)) { return value.map((item) => normalizeEntryValue(item)); } if (typeof value === "object") { const record = value as Record; const normalized: Record = {}; Object.keys(record) .sort() .forEach((key) => { normalized[key] = normalizeEntryValue(record[key]); }); return normalized; } return value; } function getCustomFieldRecordEntry( record: Record, key: string ): CustomFieldRecordEntry { return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; } const DEFAULT_TICKETS_LIST_LIMIT = 120; const MIN_TICKETS_LIST_LIMIT = 25; const MAX_TICKETS_LIST_LIMIT = 400; const MAX_FETCH_LIMIT = 400; const BASE_FETCH_PADDING = 50; const SEARCH_FETCH_PADDING = 200; function clampTicketLimit(limit: number) { if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit))); } function computeFetchLimit(limit: number, hasSearch: boolean) { const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING; const target = limit + padding; return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target)); } async function loadDocs( ctx: QueryCtx, ids: (Id | null | undefined)[], ): Promise>> { const uniqueIds = Array.from( new Set(ids.filter((value): value is Id => Boolean(value))), ); if (uniqueIds.length === 0) { return new Map(); } const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id))); const map = new Map>(); docs.forEach((doc, index) => { if (doc) { map.set(String(uniqueIds[index]), doc); } }); return map; } function dedupeTicketsById(tickets: Doc<"tickets">[]) { const seen = new Set(); const result: Doc<"tickets">[] = []; for (const ticket of tickets) { const key = String(ticket._id); if (seen.has(key)) continue; seen.add(key); result.push(ticket); } return result; } export const list = query({ args: { viewerId: v.optional(v.id("users")), tenantId: v.string(), status: v.optional(v.string()), priority: v.optional(v.union(v.string(), v.array(v.string()))), channel: v.optional(v.string()), queueId: v.optional(v.id("queues")), assigneeId: v.optional(v.id("users")), requesterId: v.optional(v.id("users")), search: v.optional(v.string()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { if (!args.viewerId) { return []; } const viewerId = args.viewerId as Id<"users">; const { user, role } = await requireUser(ctx, viewerId, args.tenantId); if (role === "MANAGER" && !user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; const normalizedPriorityFilter = normalizePriorityFilter(args.priority); const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; const searchTerm = args.search?.trim().toLowerCase() ?? null; const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT; const requestedLimit = clampTicketLimit(requestedLimitRaw); const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm)); let base: Doc<"tickets">[] = []; if (role === "MANAGER") { const baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.assigneeId) { const baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.requesterId) { const baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.queueId) { const baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (normalizedStatusFilter) { const baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)); base = await baseQuery.order("desc").take(fetchLimit); } else if (role === "COLLABORATOR") { const viewerEmail = user.email.trim().toLowerCase(); const directQuery = ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId)); const directTickets = await directQuery.order("desc").take(fetchLimit); let combined = directTickets; if (directTickets.length < fetchLimit) { const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit); const fallbackMatches = fallbackRaw.filter((ticket) => { const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email; if (typeof snapshotEmail !== "string") return false; return snapshotEmail.trim().toLowerCase() === viewerEmail; }); combined = dedupeTicketsById([...directTickets, ...fallbackMatches]); } base = combined.slice(0, fetchLimit); } else { const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); base = await baseQuery.order("desc").take(fetchLimit); } let filtered = base; if (role === "MANAGER") { filtered = filtered.filter((t) => t.companyId === user.companyId); } if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority)); if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); if (normalizedStatusFilter) { filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); } if (searchTerm) { filtered = filtered.filter( (t) => t.subject.toLowerCase().includes(searchTerm) || t.summary?.toLowerCase().includes(searchTerm) || `#${t.reference}`.toLowerCase().includes(searchTerm) ); } const limited = filtered.slice(0, requestedLimit); if (limited.length === 0) { return []; } const [ requesterDocs, assigneeDocs, queueDocs, companyDocs, machineDocs, activeSessionDocs, categoryDocs, subcategoryDocs, ] = await Promise.all([ loadDocs(ctx, limited.map((t) => t.requesterId)), loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)), loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)), ]); const serverNow = Date.now(); const result = limited.map((t) => { const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined; const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null; const requesterSummary = requesterDoc ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id }) : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id }); const assigneeDoc = t.assigneeId ? assigneeDocs.get(String(t.assigneeId)) ?? null : null; const assigneeSummary = t.assigneeId ? assigneeDoc ? { id: assigneeDoc._id, name: assigneeDoc.name, email: assigneeDoc.email, avatarUrl: assigneeDoc.avatarUrl, teams: normalizeTeams(assigneeDoc.teams), } : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) : null; const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null; const queueName = normalizeQueueName(queueDoc); const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null; const companySummary = companyDoc ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false } : t.companyId || t.companySnapshot ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) : null; const machineSnapshot = t.machineSnapshot as | { hostname?: string; persona?: string; assignedUserName?: string; assignedUserEmail?: string; status?: string; } | undefined; const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null; let machineSummary: | { id: Id<"machines"> | null; hostname: string | null; persona: string | null; assignedUserName: string | null; assignedUserEmail: string | null; status: string | null; } | null = null; if (t.machineId) { machineSummary = { id: t.machineId, hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, status: machineDoc?.status ?? machineSnapshot?.status ?? null, }; } else if (machineSnapshot) { machineSummary = { id: null, hostname: machineSnapshot.hostname ?? null, persona: machineSnapshot.persona ?? null, assignedUserName: machineSnapshot.assignedUserName ?? null, assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, status: machineSnapshot.status ?? null, }; } const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null; const categorySummary = categoryDoc ? { id: categoryDoc._id, name: categoryDoc.name } : null; const subcategoryDoc = t.subcategoryId ? subcategoryDocs.get(String(t.subcategoryId)) ?? null : null; const subcategorySummary = subcategoryDoc ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId } : null; const activeSessionDoc = t.activeSessionId ? activeSessionDocs.get(String(t.activeSessionId)) ?? null : null; const activeSession = activeSessionDoc ? { id: activeSessionDoc._id, agentId: activeSessionDoc.agentId, startedAt: activeSessionDoc.startedAt, workType: activeSessionDoc.workType ?? "INTERNAL", } : null; return { id: t._id, reference: t.reference, tenantId: t.tenantId, subject: t.subject, summary: t.summary, status: normalizeStatus(t.status), priority: t.priority, channel: t.channel, queue: queueName, csatScore: typeof t.csatScore === "number" ? t.csatScore : null, csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, csatRatedAt: t.csatRatedAt ?? null, csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, formTemplate: t.formTemplate ?? null, formTemplateLabel: resolveFormTemplateLabel( t.formTemplate ?? null, t.formTemplateLabel ?? null, ), company: companySummary, requester: requesterSummary, assignee: assigneeSummary, slaPolicy: null, dueAt: t.dueAt ?? null, visitStatus: t.visitStatus ?? null, visitPerformedAt: t.visitPerformedAt ?? null, firstResponseAt: t.firstResponseAt ?? null, resolvedAt: t.resolvedAt ?? null, updatedAt: t.updatedAt, createdAt: t.createdAt, tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, category: categorySummary, subcategory: subcategorySummary, machine: machineSummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, internalWorkedMs: t.internalWorkedMs ?? 0, externalWorkedMs: t.externalWorkedMs ?? 0, serverNow, activeSession, }, }; }); // sort by updatedAt desc return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); }, }); export const getById = query({ args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") }, handler: async (ctx, { tenantId, id, viewerId }) => { const { user, role } = await requireUser(ctx, viewerId, tenantId) const t = await ctx.db.get(id); if (!t || t.tenantId !== tenantId) return null; if (role === "COLLABORATOR") { const isOwnerById = String(t.requesterId) === String(viewerId) const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null const viewerEmail = user.email.trim().toLowerCase() const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail) if (!isOwnerById && !isOwnerByEmail) { return null } } // no customer role; managers are constrained to company via ensureManagerTicketAccess let requester: Doc<"users"> | null = null if (role === "MANAGER") { requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null } if (!requester) { requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null } const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; const machineSnapshot = t.machineSnapshot as | { hostname?: string persona?: string assignedUserName?: string assignedUserEmail?: string status?: string } | undefined; let machineSummary: | { id: Id<"machines"> | null hostname: string | null persona: string | null assignedUserName: string | null assignedUserEmail: string | null status: string | null } | null = null; if (t.machineId) { const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null; machineSummary = { id: t.machineId, hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, status: machineDoc?.status ?? machineSnapshot?.status ?? null, }; } else if (machineSnapshot) { machineSummary = { id: null, hostname: machineSnapshot.hostname ?? null, persona: machineSnapshot.persona ?? null, assignedUserName: machineSnapshot.assignedUserName ?? null, assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, status: machineSnapshot.status ?? null, }; } const queueName = normalizeQueueName(queue); const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) .take(50); const canViewInternalComments = role === "ADMIN" || role === "AGENT"; const visibleComments = canViewInternalComments ? comments : comments.filter((comment) => comment.visibility !== "INTERNAL"); const visibleCommentKeys = new Set( visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`) ) const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt)) const serverNow = Date.now() let timelineRecords = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) .take(50); if (!(role === "ADMIN" || role === "AGENT")) { timelineRecords = timelineRecords.filter((event) => { const payload = (event.payload ?? {}) as Record switch (event.type) { case "CREATED": return true case "QUEUE_CHANGED": return true case "ASSIGNEE_CHANGED": return true case "CATEGORY_CHANGED": return true case "COMMENT_ADDED": { const authorIdRaw = (payload as { authorId?: string }).authorId if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) { const key = `${event.createdAt}:${authorIdRaw}` if (visibleCommentKeys.has(key)) { return true } } return visibleCommentTimestamps.has(event.createdAt) } case "STATUS_CHANGED": { const toLabelRaw = (payload as { toLabel?: string }).toLabel const toRaw = (payload as { to?: string }).to const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0 ? toLabelRaw.trim() : typeof toRaw === "string" ? toRaw.trim() : "").toUpperCase() if (!normalized) return false return ( normalized === "RESOLVED" || normalized === "RESOLVIDO" || normalized === "CLOSED" || normalized === "FINALIZADO" || normalized === "FINALIZED" ) } default: return false } }) } const customFieldsRecord = mapCustomFieldsToRecord( (t.customFields as NormalizedCustomField[] | undefined) ?? undefined ); const commentsHydrated = await Promise.all( visibleComments.map(async (c) => { const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null; const attachments = await Promise.all( (c.attachments ?? []).map(async (att) => ({ id: att.storageId, name: att.name, size: att.size, type: att.type, url: await ctx.storage.getUrl(att.storageId), })) ); const authorSummary = buildCommentAuthorSummary(c, author, { ticketId: t._id, commentId: c._id, }); return { id: c._id, author: authorSummary, visibility: c.visibility, body: c.body, attachments, createdAt: c.createdAt, updatedAt: c.updatedAt, }; }) ); const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow); return { id: t._id, reference: t.reference, tenantId: t.tenantId, subject: t.subject, summary: t.summary, status: normalizeStatus(t.status), priority: t.priority, channel: t.channel, queue: queueName, 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, firstResponseAt: t.firstResponseAt ?? null, resolvedAt: t.resolvedAt ?? null, updatedAt: t.updatedAt, createdAt: t.createdAt, tags: t.tags ?? [], checklist: Array.isArray(t.checklist) ? t.checklist.map((item) => ({ id: item.id, text: item.text, description: item.description ?? undefined, type: item.type ?? "checkbox", options: item.options ?? undefined, answer: item.answer ?? undefined, done: item.done, required: typeof item.required === "boolean" ? item.required : true, templateId: item.templateId ? String(item.templateId) : undefined, templateItemId: item.templateItemId ?? undefined, templateDescription: item.templateDescription ?? undefined, createdAt: item.createdAt ?? undefined, createdBy: item.createdBy ? String(item.createdBy) : undefined, doneAt: item.doneAt ?? undefined, doneBy: item.doneBy ? String(item.doneBy) : undefined, })) : [], lastTimelineEntry: null, metrics: null, csatScore: typeof t.csatScore === "number" ? t.csatScore : null, csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, csatRatedAt: t.csatRatedAt ?? null, csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, machine: machineSummary, category: category ? { id: category._id, name: category.name, } : null, subcategory: subcategory ? { id: subcategory._id, name: subcategory.name, categoryId: subcategory.categoryId, } : null, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, internalWorkedMs: t.internalWorkedMs ?? 0, externalWorkedMs: t.externalWorkedMs ?? 0, serverNow, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: activeSession.workType ?? "INTERNAL", } : null, perAgentTotals: perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName, agentEmail: item.agentEmail, avatarUrl: item.avatarUrl, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })), }, formTemplate: t.formTemplate ?? null, formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), chatEnabled: Boolean(t.chatEnabled), relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [], resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null, reopenDeadline: t.reopenDeadline ?? null, reopenedAt: t.reopenedAt ?? null, visitStatus: t.visitStatus ?? null, visitPerformedAt: t.visitPerformedAt ?? null, description: undefined, customFields: customFieldsRecord, timeline: consolidateChatEventsInTimeline(timelineRecords).map((ev) => { let payload = ev.payload; if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null); if (normalized && normalized !== (payload as { queueName?: string }).queueName) { payload = { ...payload, queueName: normalized }; } } return { id: ev._id, type: ev.type, payload, createdAt: ev.createdAt, }; }), comments: commentsHydrated, }; }, }); export const create = mutation({ args: { actorId: v.id("users"), tenantId: v.string(), subject: v.string(), summary: v.optional(v.string()), priority: v.string(), channel: v.string(), queueId: v.optional(v.id("queues")), requesterId: v.id("users"), assigneeId: v.optional(v.id("users")), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), machineId: v.optional(v.id("machines")), checklist: v.optional( v.array( v.object({ text: v.string(), required: v.optional(v.boolean()), }) ) ), checklistTemplateIds: v.optional(v.array(v.id("ticketChecklistTemplates"))), customFields: v.optional( v.array( v.object({ fieldId: v.id("ticketFields"), value: v.any(), }) ) ), formTemplate: v.optional(v.string()), chatEnabled: v.optional(v.boolean()), visitDate: v.optional(v.number()), }, handler: async (ctx, args) => { const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) // no customer role; managers validated below if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) { throw new ConvexError("Somente a equipe interna pode definir o responsável") } let initialAssigneeId: Id<"users"> | undefined let initialAssignee: Doc<"users"> | null = null if (args.assigneeId) { const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null if (!assignee || assignee.tenantId !== args.tenantId) { throw new ConvexError("Responsável inválido") } const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase() if (!STAFF_ROLES.has(normalizedAssigneeRole)) { throw new ConvexError("Responsável inválido") } initialAssigneeId = assignee._id initialAssignee = assignee } else if (role && INTERNAL_STAFF_ROLES.has(role)) { initialAssigneeId = actorUser._id initialAssignee = actorUser } const subject = args.subject.trim(); if (subject.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); } if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) { throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`); } const category = await ctx.db.get(args.categoryId); if (!category || category.tenantId !== args.tenantId) { throw new ConvexError("Categoria inválida"); } const subcategory = await ctx.db.get(args.subcategoryId); if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) { throw new ConvexError("Subcategoria inválida"); } const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null if (!requester || requester.tenantId !== args.tenantId) { throw new ConvexError("Solicitante inválido") } if (role === "MANAGER") { if (!actorUser.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } if (requester.companyId !== actorUser.companyId) { throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa") } } let machineDoc: Doc<"machines"> | null = null if (args.machineId) { const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null if (!machine || machine.tenantId !== args.tenantId) { throw new ConvexError("Dispositivo inválida para este chamado") } machineDoc = machine } let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null); let formTemplateLabel: string | null = null; if (formTemplateKey) { const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey); if (templateDoc && templateDoc.isArchived !== true) { formTemplateLabel = templateDoc.label; } else { const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey); if (fallbackTemplate) { formTemplateLabel = fallbackTemplate.label; } else { formTemplateKey = null; } } } const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true; const normalizedCustomFields = await normalizeCustomFieldValues( ctx, args.tenantId, args.customFields ?? undefined, formTemplateKey, ); // compute next reference (simple monotonic counter per tenant) const existing = await ctx.db .query("tickets") .withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId)) .order("desc") .take(1); const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000; const now = Date.now(); const initialStatus: TicketStatusNormalized = "PENDING"; const manualChecklist: TicketChecklistItem[] = (args.checklist ?? []).map((entry) => { const text = normalizeChecklistText(entry.text ?? ""); if (!text) { throw new ConvexError("Item do checklist inválido.") } if (text.length > 240) { throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).") } return { id: crypto.randomUUID(), text, done: false, required: typeof entry.required === "boolean" ? entry.required : true, createdAt: now, createdBy: args.actorId, } }) const requesterSnapshot = { name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null if (!companyDoc && machineDoc?.companyId) { const candidateCompany = await ctx.db.get(machineDoc.companyId) if (candidateCompany && candidateCompany.tenantId === args.tenantId) { companyDoc = candidateCompany as Doc<"companies"> } } const companySnapshot = companyDoc ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } : undefined const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined let checklist = manualChecklist for (const templateId of args.checklistTemplateIds ?? []) { const template = await ctx.db.get(templateId) if (!template || template.tenantId !== args.tenantId || template.isArchived === true) { throw new ConvexError("Template de checklist inválido.") } if (template.companyId && (!resolvedCompanyId || String(template.companyId) !== String(resolvedCompanyId))) { throw new ConvexError("Template de checklist não pertence à empresa do ticket.") } checklist = applyChecklistTemplateToItems(checklist, template, { now, actorId: args.actorId }).checklist } const assigneeSnapshot = initialAssignee ? { name: initialAssignee.name, email: initialAssignee.email, avatarUrl: initialAssignee.avatarUrl ?? undefined, teams: initialAssignee.teams ?? undefined, } : undefined // default queue: if none provided, prefer "Chamados" let resolvedQueueId = args.queueId as Id<"queues"> | undefined if (!resolvedQueueId) { const queues = await ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) .take(100) const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null if (preferred) { resolvedQueueId = preferred._id as Id<"queues"> } } const slaFields = applySlaSnapshot(slaSnapshot, now) let resolvedQueueDoc: Doc<"queues"> | null = null if (resolvedQueueId) { const queueDoc = await ctx.db.get(resolvedQueueId) if (queueDoc && queueDoc.tenantId === args.tenantId) { resolvedQueueDoc = queueDoc as Doc<"queues"> } } const queueLabel = (resolvedQueueDoc?.slug ?? resolvedQueueDoc?.name ?? "").toLowerCase() const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword)) const visitDueAt = typeof args.visitDate === "number" && Number.isFinite(args.visitDate) ? args.visitDate : null if (isVisitQueue && !visitDueAt) { throw new ConvexError("Informe a data da visita para tickets da fila de visitas") } const id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, subject, summary: args.summary?.trim() || undefined, status: initialStatus, priority: args.priority, channel: args.channel, queueId: resolvedQueueId, categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, requesterSnapshot, assigneeId: initialAssigneeId, assigneeSnapshot, companyId: resolvedCompanyId, companySnapshot, machineId: machineDoc?._id ?? undefined, machineSnapshot: machineDoc ? { hostname: machineDoc.hostname ?? undefined, persona: machineDoc.persona ?? undefined, assignedUserName: machineDoc.assignedUserName ?? undefined, assignedUserEmail: machineDoc.assignedUserEmail ?? undefined, status: machineDoc.status ?? undefined, } : undefined, formTemplate: formTemplateKey ?? undefined, formTemplateLabel: formTemplateLabel ?? undefined, chatEnabled, working: false, activeSessionId: undefined, totalWorkedMs: 0, createdAt: now, updatedAt: now, firstResponseAt: undefined, resolvedAt: undefined, closedAt: undefined, tags: [], checklist: checklist.length > 0 ? checklist : undefined, slaPolicyId: undefined, dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, visitStatus: isVisitQueue ? "scheduled" : undefined, visitPerformedAt: undefined, ...slaFields, }); await ctx.db.insert("ticketEvents", { ticketId: id, type: "CREATED", payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, createdAt: now, }); // Notificação por e-mail: ticket criado para o solicitante try { const requesterEmail = requester?.email if (requesterEmail) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, { to: requesterEmail, userId: String(requester._id), userName: requester.name ?? undefined, ticketId: String(id), reference: nextRef, subject, priority: args.priority, tenantId: args.tenantId, }) } } } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e) } if (initialAssigneeId && initialAssignee) { await ctx.db.insert("ticketEvents", { ticketId: id, type: "ASSIGNEE_CHANGED", payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId, actorName: actorUser.name, actorAvatar: actorUser.avatarUrl ?? undefined, previousAssigneeId: null, previousAssigneeName: "Não atribuído", }, createdAt: now, }) } await runTicketAutomationsForEvent(ctx, { tenantId: args.tenantId, ticketId: id, eventType: "TICKET_CREATED", }) return id; }, }); function ensureChecklistEditor(viewer: { role: string | null }) { const normalizedRole = (viewer.role ?? "").toUpperCase(); if (!INTERNAL_STAFF_ROLES.has(normalizedRole)) { throw new ConvexError("Apenas administradores e agentes podem alterar o checklist."); } } function normalizeTicketChecklist(list: unknown): TicketChecklistItem[] { if (!Array.isArray(list)) return []; return list as TicketChecklistItem[]; } export const addChecklistItem = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), text: v.string(), required: v.optional(v.boolean()), }, handler: async (ctx, { ticketId, actorId, text, required }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const normalizedText = normalizeChecklistText(text); if (!normalizedText) { throw new ConvexError("Informe o texto do item do checklist."); } if (normalizedText.length > 240) { throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres)."); } const now = Date.now(); const item: TicketChecklistItem = { id: crypto.randomUUID(), text: normalizedText, done: false, required: typeof required === "boolean" ? required : true, createdAt: now, createdBy: actorId, }; const checklist = normalizeTicketChecklist(ticketDoc.checklist).concat(item); await ctx.db.patch(ticketId, { checklist: checklist.length > 0 ? checklist : undefined, updatedAt: now, }); return { ok: true, item }; }, }); export const updateChecklistItemText = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), itemId: v.string(), text: v.string(), }, handler: async (ctx, { ticketId, actorId, itemId, text }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const normalizedText = normalizeChecklistText(text); if (!normalizedText) { throw new ConvexError("Informe o texto do item do checklist."); } if (normalizedText.length > 240) { throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres)."); } const checklist = normalizeTicketChecklist(ticketDoc.checklist); const index = checklist.findIndex((item) => item.id === itemId); if (index < 0) { throw new ConvexError("Item do checklist não encontrado."); } const nextChecklist = checklist.map((item) => item.id === itemId ? { ...item, text: normalizedText } : item ); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() }); return { ok: true }; }, }); export const setChecklistItemDone = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), itemId: v.string(), done: v.boolean(), }, handler: async (ctx, { ticketId, actorId, itemId, done }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; await requireTicketStaff(ctx, actorId, ticketDoc); const checklist = normalizeTicketChecklist(ticketDoc.checklist); const index = checklist.findIndex((item) => item.id === itemId); if (index < 0) { throw new ConvexError("Item do checklist não encontrado."); } const previous = checklist[index]!; if (previous.done === done) { return { ok: true }; } const now = Date.now(); const nextChecklist = checklist.map((item) => { if (item.id !== itemId) return item; if (done) { return { ...item, done: true, doneAt: now, doneBy: actorId }; } return { ...item, done: false, doneAt: undefined, doneBy: undefined }; }); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); return { ok: true }; }, }); export const setChecklistItemRequired = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), itemId: v.string(), required: v.boolean(), }, handler: async (ctx, { ticketId, actorId, itemId, required }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const checklist = normalizeTicketChecklist(ticketDoc.checklist); const index = checklist.findIndex((item) => item.id === itemId); if (index < 0) { throw new ConvexError("Item do checklist não encontrado."); } const nextChecklist = checklist.map((item) => (item.id === itemId ? { ...item, required: Boolean(required) } : item)); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() }); return { ok: true }; }, }); export const setChecklistItemAnswer = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), itemId: v.string(), answer: v.optional(v.string()), }, handler: async (ctx, { ticketId, actorId, itemId, answer }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; await requireTicketStaff(ctx, actorId, ticketDoc); const checklist = normalizeTicketChecklist(ticketDoc.checklist); const index = checklist.findIndex((item) => item.id === itemId); if (index < 0) { throw new ConvexError("Item do checklist não encontrado."); } const item = checklist[index]!; if (item.type !== "question") { throw new ConvexError("Este item não é uma pergunta."); } const now = Date.now(); const normalizedAnswer = answer?.trim() ?? ""; const isDone = normalizedAnswer.length > 0; const nextChecklist = checklist.map((it) => { if (it.id !== itemId) return it; if (isDone) { return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId }; } return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined }; }); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); return { ok: true }; }, }); export const removeChecklistItem = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), itemId: v.string(), }, handler: async (ctx, { ticketId, actorId, itemId }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const checklist = normalizeTicketChecklist(ticketDoc.checklist); const nextChecklist = checklist.filter((item) => item.id !== itemId); await ctx.db.patch(ticketId, { checklist: nextChecklist.length > 0 ? nextChecklist : undefined, updatedAt: Date.now(), }); return { ok: true }; }, }); export const completeAllChecklistItems = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), }, handler: async (ctx, { ticketId, actorId }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const checklist = normalizeTicketChecklist(ticketDoc.checklist); if (checklist.length === 0) return { ok: true }; const now = Date.now(); const nextChecklist = checklist.map((item) => { if (item.done === true) return item; return { ...item, done: true, doneAt: now, doneBy: actorId }; }); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); return { ok: true }; }, }); export const uncompleteAllChecklistItems = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), }, handler: async (ctx, { ticketId, actorId }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const checklist = normalizeTicketChecklist(ticketDoc.checklist); if (checklist.length === 0) return { ok: true }; const now = Date.now(); const nextChecklist = checklist.map((item) => { if (item.done === false) return item; return { ...item, done: false, doneAt: undefined, doneBy: undefined }; }); await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); return { ok: true }; }, }); export const applyChecklistTemplate = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), templateId: v.id("ticketChecklistTemplates"), }, handler: async (ctx, { ticketId, actorId, templateId }) => { const ticket = await ctx.db.get(ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado"); } const ticketDoc = ticket as Doc<"tickets">; const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); ensureChecklistEditor(viewer); const template = await ctx.db.get(templateId); if (!template || template.tenantId !== ticketDoc.tenantId || template.isArchived === true) { throw new ConvexError("Template de checklist inválido."); } if (template.companyId && (!ticketDoc.companyId || String(template.companyId) !== String(ticketDoc.companyId))) { throw new ConvexError("Template de checklist não pertence à empresa do ticket."); } const now = Date.now(); const current = normalizeTicketChecklist(ticketDoc.checklist); // DEBUG: Verificar se o template tem description console.log("[DEBUG applyChecklistTemplate] Template:", { id: String(template._id), name: template.name, description: template.description, hasDescription: Boolean(template.description), }); const result = applyChecklistTemplateToItems(current, template, { now, actorId }); if (result.added === 0) { return { ok: true, added: 0 }; } await ctx.db.patch(ticketId, { checklist: result.checklist, updatedAt: now }); return { ok: true, added: result.added }; }, }); export const addComment = mutation({ args: { ticketId: v.id("tickets"), authorId: v.id("users"), visibility: v.string(), body: v.string(), attachments: v.optional( v.array( v.object({ storageId: v.id("_storage"), name: v.string(), size: v.optional(v.number()), type: v.optional(v.string()), }) ) ), }, handler: async (ctx, args) => { const ticket = await ctx.db.get(args.ticketId); if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null if (!author || author.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Autor do comentário inválido") } const normalizedRole = (author.role ?? "AGENT").toUpperCase() const requestedVisibility = (args.visibility ?? "").toUpperCase() if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") { throw new ConvexError("Visibilidade inválida") } if (normalizedRole === "MANAGER") { await ensureManagerTicketAccess(ctx, author, ticketDoc) if (requestedVisibility !== "PUBLIC") { throw new ConvexError("Gestores só podem registrar comentários públicos") } } const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT" if (requestedVisibility === "INTERNAL" && !canUseInternalComments) { throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos") } // Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável. // O solicitante (colaborador) pode comentar sempre. const isRequester = String(ticketDoc.requesterId) === String(author._id) const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT" const hasAssignee = Boolean(ticketDoc.assigneeId) // Gestores podem comentar mesmo sem responsável; admin/agent só com responsável if (!isRequester && isAdminOrAgent && !hasAssignee) { throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.") } if (ticketDoc.requesterId === args.authorId) { // O próprio solicitante pode comentar seu ticket. // Comentários internos já são bloqueados acima para quem não é STAFF. // Portanto, nada a fazer aqui. } else { await requireTicketStaff(ctx, args.authorId, ticketDoc) } const attachments = args.attachments ?? [] if (attachments.length > 5) { throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário") } const maxAttachmentSize = 5 * 1024 * 1024 for (const attachment of attachments) { if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) { throw new ConvexError("Cada anexo pode ter até 5MB") } } const authorSnapshot: CommentAuthorSnapshot = { name: author.name, email: author.email, avatarUrl: author.avatarUrl ?? undefined, teams: author.teams ?? undefined, }; const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId) const bodyPlainLen = plainTextLength(normalizedBody) if (bodyPlainLen > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } const now = Date.now(); const id = await ctx.db.insert("ticketComments", { ticketId: args.ticketId, authorId: args.authorId, visibility: requestedVisibility, body: normalizedBody, authorSnapshot, attachments, createdAt: now, updatedAt: now, }); await ctx.db.insert("ticketEvents", { ticketId: args.ticketId, type: "COMMENT_ADDED", payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, createdAt: now, }); const isStaffResponder = requestedVisibility === "PUBLIC" && !isRequester && (normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER"); const responsePatch = isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {}; await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch }); // Notificação por e-mail: comentário público para o solicitante try { const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined const snapshotEmail = requesterSnapshot?.email if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { to: snapshotEmail, userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, userName: requesterSnapshot?.name ?? undefined, ticketId: String(ticketDoc._id), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", tenantId: ticketDoc.tenantId, }) } } } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de comentário", e) } await runTicketAutomationsForEvent(ctx, { tenantId: ticketDoc.tenantId, ticketId: args.ticketId, eventType: "COMMENT_ADDED", }) return id; }, }); export const updateComment = mutation({ args: { ticketId: v.id("tickets"), commentId: v.id("ticketComments"), actorId: v.id("users"), body: v.string(), }, handler: async (ctx, { ticketId, commentId, actorId, body }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null if (!actor || actor.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Autor do comentário inválido") } const comment = await ctx.db.get(commentId); if (!comment || comment.ticketId !== ticketId) { throw new ConvexError("Comentário não encontrado"); } if (comment.authorId !== actorId) { throw new ConvexError("Você não tem permissão para editar este comentário"); } const normalizedRole = (actor.role ?? "AGENT").toUpperCase() if (ticketDoc.requesterId === actorId) { if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, actorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para editar") } } else { await requireTicketStaff(ctx, actorId, ticketDoc) } const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId) const bodyPlainLen = plainTextLength(normalizedBody) if (bodyPlainLen > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } const now = Date.now(); await ctx.db.patch(commentId, { body: normalizedBody, updatedAt: now, }); await ctx.db.insert("ticketEvents", { ticketId, type: "COMMENT_EDITED", payload: { commentId, actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, }, createdAt: now, }); await ctx.db.patch(ticketId, { updatedAt: now }); }, }); export const removeCommentAttachment = mutation({ args: { ticketId: v.id("tickets"), commentId: v.id("ticketComments"), attachmentId: v.id("_storage"), actorId: v.id("users"), }, handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null if (!actor || actor.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Autor do comentário inválido") } const comment = await ctx.db.get(commentId); if (!comment || comment.ticketId !== ticketId) { throw new ConvexError("Comentário não encontrado"); } if (comment.authorId !== actorId) { throw new ConvexError("Você não pode alterar anexos de outro usuário") } const normalizedRole = (actor.role ?? "AGENT").toUpperCase() if (ticketDoc.requesterId === actorId) { if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, actorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para alterar anexos") } } else { await requireTicketStaff(ctx, actorId, ticketDoc) } const attachments = comment.attachments ?? []; const target = attachments.find((att) => att.storageId === attachmentId); if (!target) { throw new ConvexError("Anexo não encontrado"); } await ctx.storage.delete(attachmentId); const now = Date.now(); await ctx.db.patch(commentId, { attachments: attachments.filter((att) => att.storageId !== attachmentId), updatedAt: now, }); await ctx.db.insert("ticketEvents", { ticketId, type: "ATTACHMENT_REMOVED", payload: { attachmentId, attachmentName: target.name, actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, }, createdAt: now, }); await ctx.db.patch(ticketId, { updatedAt: now }); }, }); export const updateStatus = mutation({ args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, status, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> await requireTicketStaff(ctx, actorId, ticketDoc) const normalizedStatus = normalizeStatus(status) if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) { throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.") } const now = Date.now(); const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now); await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch }); await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }); await runTicketAutomationsForEvent(ctx, { tenantId: ticketDoc.tenantId, ticketId, eventType: "STATUS_CHANGED", }) }, }); export async function resolveTicketHandler( ctx: MutationCtx, { ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: { ticketId: Id<"tickets"> actorId: Id<"users"> resolvedWithTicketId?: Id<"tickets"> relatedTicketIds?: Id<"tickets">[] reopenWindowDays?: number | null } ) { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const now = Date.now() if (checklistBlocksResolution((ticketDoc.checklist ?? []) as unknown as TicketChecklistItem[])) { throw new ConvexError("Conclua todos os itens obrigatórios do checklist antes de encerrar o ticket.") } const baseRelated = new Set() for (const rel of relatedTicketIds ?? []) { if (String(rel) === String(ticketId)) continue baseRelated.add(String(rel)) } if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) { baseRelated.add(String(resolvedWithTicketId)) } const linkedTickets: Doc<"tickets">[] = [] for (const id of baseRelated) { const related = await ctx.db.get(id as Id<"tickets">) if (!related) continue if (related.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Chamado vinculado pertence a outro tenant") } linkedTickets.push(related as Doc<"tickets">) } const resolvedWith = resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId) ? (await ctx.db.get(resolvedWithTicketId)) ?? null : null if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Chamado vinculado pertence a outro tenant") } if (resolvedWithTicketId && !resolvedWith) { throw new ConvexError("Chamado vinculado não encontrado") } const reopenDays = resolveReopenWindowDays(reopenWindowDays) const reopenDeadline = computeReopenDeadline(now, reopenDays) const normalizedStatus = "RESOLVED" const relatedIdList = Array.from( new Set( linkedTickets.map((rel) => String(rel._id)), ), ).map((id) => id as Id<"tickets">) const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now); const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch); const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now); await ctx.db.patch(ticketId, { status: normalizedStatus, resolvedAt: now, closedAt: now, updatedAt: now, reopenDeadline, reopenedAt: undefined, resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined, relatedTicketIds: relatedIdList.length ? relatedIdList : undefined, activeSessionId: undefined, working: false, ...slaPausePatch, ...slaSolutionPatch, }) await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }) // Notificação por e-mail: encerramento do chamado try { const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined const email = requesterDoc?.email || requesterSnapshot?.email || null const userName = requesterDoc?.name || requesterSnapshot?.name || undefined if (email) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { to: email, userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, userName, ticketId: String(ticketId), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", tenantId: ticketDoc.tenantId, }) } } } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de encerramento", e) } for (const rel of linkedTickets) { const existing = new Set((rel.relatedTicketIds ?? []).map((value) => String(value))) existing.add(String(ticketId)) await ctx.db.patch(rel._id, { relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">), updatedAt: now, }) const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related" await ctx.db.insert("ticketEvents", { ticketId, type: "TICKET_LINKED", payload: { actorId, actorName: viewer.user.name, linkedTicketId: rel._id, linkedReference: rel.reference ?? null, linkedSubject: rel.subject ?? null, kind: linkKind, }, createdAt: now, }) await ctx.db.insert("ticketEvents", { ticketId: rel._id, type: "TICKET_LINKED", payload: { actorId, actorName: viewer.user.name, linkedTicketId: ticketId, linkedReference: ticketDoc.reference ?? null, linkedSubject: ticketDoc.subject ?? null, kind: linkKind === "resolved_with" ? "resolution_parent" : "related", }, createdAt: now, }) } await runTicketAutomationsForEvent(ctx, { tenantId: ticketDoc.tenantId, ticketId, eventType: "TICKET_RESOLVED", }) return { ok: true, reopenDeadline, reopenWindowDays: reopenDays } } export const resolveTicket = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), resolvedWithTicketId: v.optional(v.id("tickets")), relatedTicketIds: v.optional(v.array(v.id("tickets"))), reopenWindowDays: v.optional(v.number()), }, handler: resolveTicketHandler, }) export async function reopenTicketHandler( ctx: MutationCtx, { ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> } ) { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId) const normalizedRole = viewer.role ?? "" const now = Date.now() const status = normalizeStatus(ticketDoc.status) if (status !== "RESOLVED") { throw new ConvexError("Somente chamados resolvidos podem ser reabertos") } if (!isWithinReopenWindow(ticketDoc, now)) { throw new ConvexError("O prazo para reabrir este chamado expirou") } if (normalizedRole === "COLLABORATOR") { if (String(ticketDoc.requesterId) !== String(actorId)) { throw new ConvexError("Somente o solicitante pode reabrir este chamado") } } else if (normalizedRole === "MANAGER") { await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc) } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { throw new ConvexError("Usuário não possui permissão para reabrir este chamado") } const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now) await ctx.db.patch(ticketId, { status: "AWAITING_ATTENDANCE", reopenedAt: now, resolvedAt: undefined, closedAt: undefined, updatedAt: now, ...slaPatch, }) await ctx.db.insert("ticketEvents", { ticketId, type: "TICKET_REOPENED", payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole }, createdAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId }, createdAt: now, }) return { ok: true, reopenedAt: now } } export const reopenTicket = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), }, handler: reopenTicketHandler, }) export const changeAssignee = mutation({ args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users"), reason: v.optional(v.string()), }, handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const viewerUser = viewer.user const isAdmin = viewer.role === "ADMIN" const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null if (!assignee || assignee.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Responsável inválido") } if (viewer.role === "MANAGER") { throw new ConvexError("Gestores não podem reatribuir chamados") } const hasActiveSession = Boolean(ticketDoc.activeSessionId) const currentAssigneeId = ticketDoc.assigneeId ?? null if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { throw new ConvexError("Somente o responsável atual pode reatribuir este chamado") } const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim() if (normalizedReason.length > 0 && normalizedReason.length < 5) { throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco") } if (normalizedReason.length > 1000) { throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)") } const previousAssigneeName = ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído" const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável" const now = Date.now(); const assigneeSnapshot = { name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl ?? undefined, teams: assignee.teams ?? undefined, } const ticketPatch: Partial> = { assigneeId, assigneeSnapshot, updatedAt: now, } if (hasActiveSession) { const session = await ctx.db.get(ticketDoc.activeSessionId as Id<"ticketWorkSessions">) if (session) { const durationMs = Math.max(0, now - session.startedAt) const sessionType = (session.workType ?? "INTERNAL").toUpperCase() const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 await ctx.db.patch(session._id, { stoppedAt: now, durationMs, }) ticketPatch.totalWorkedMs = (ticketDoc.totalWorkedMs ?? 0) + durationMs ticketPatch.internalWorkedMs = (ticketDoc.internalWorkedMs ?? 0) + deltaInternal ticketPatch.externalWorkedMs = (ticketDoc.externalWorkedMs ?? 0) + deltaExternal const newSessionId = await ctx.db.insert("ticketWorkSessions", { ticketId, agentId: assigneeId, workType: sessionType, startedAt: now, }) ticketPatch.activeSessionId = newSessionId ticketPatch.working = true ticketPatch.status = "AWAITING_ATTENDANCE" ticketDoc.totalWorkedMs = ticketPatch.totalWorkedMs as number ticketDoc.internalWorkedMs = ticketPatch.internalWorkedMs as number ticketDoc.externalWorkedMs = ticketPatch.externalWorkedMs as number ticketDoc.activeSessionId = newSessionId } else { ticketPatch.activeSessionId = undefined ticketPatch.working = false } } await ctx.db.patch(ticketId, ticketPatch); await ctx.db.insert("ticketEvents", { ticketId, type: "ASSIGNEE_CHANGED", payload: { assigneeId, assigneeName: assignee.name, actorId, actorName: viewerUser.name, actorAvatar: viewerUser.avatarUrl ?? undefined, previousAssigneeId: currentAssigneeId, previousAssigneeName, reason: normalizedReason.length > 0 ? normalizedReason : undefined, }, createdAt: now, }); }, }); export const listChatMessages = query({ args: { ticketId: v.id("tickets"), viewerId: v.id("users"), }, handler: async (ctx, { ticketId, viewerId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> await requireTicketChatParticipant(ctx, viewerId, ticketDoc) const now = Date.now() const status = normalizeStatus(ticketDoc.status) const chatEnabled = Boolean(ticketDoc.chatEnabled) const withinWindow = isWithinReopenWindow(ticketDoc, now) const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow) // Busca as 50 mensagens mais recentes (desc) e reverte para ordem cronológica const messages = await ctx.db .query("ticketChatMessages") .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId)) .order("desc") .take(50) .then((msgs) => msgs.reverse()) // Verificar maquina e sessao de chat ao vivo let liveChat: { hasMachine: boolean machineOnline: boolean machineHostname: string | null activeSession: { sessionId: Id<"liveChatSessions"> agentId: Id<"users"> agentName: string | null startedAt: number unreadByAgent: number } | null } = { hasMachine: false, machineOnline: false, machineHostname: null, activeSession: null, } if (ticketDoc.machineId) { const machine = await ctx.db.get(ticketDoc.machineId) if (machine) { const fiveMinutesAgo = now - 5 * 60 * 1000 liveChat.hasMachine = true // Usa tabela de heartbeats (fonte de verdade) com fallback para campo legado let lastHeartbeatAt: number | null = null const hb = await ctx.db .query("machineHeartbeats") .withIndex("by_machine", (q) => q.eq("machineId", machine._id)) .first() if (hb?.lastHeartbeatAt) { lastHeartbeatAt = hb.lastHeartbeatAt } else if (machine.lastHeartbeatAt) { lastHeartbeatAt = machine.lastHeartbeatAt } liveChat.machineOnline = Boolean(lastHeartbeatAt && lastHeartbeatAt > fiveMinutesAgo) liveChat.machineHostname = machine.hostname // Verificar sessao ativa const activeSession = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .filter((q) => q.eq(q.field("status"), "ACTIVE")) .first() if (activeSession) { liveChat.activeSession = { sessionId: activeSession._id, agentId: activeSession.agentId, agentName: activeSession.agentSnapshot?.name ?? null, startedAt: activeSession.startedAt, unreadByAgent: activeSession.unreadByAgent ?? 0, } } } } return { ticketId: String(ticketId), chatEnabled, status, canPost, reopenDeadline: ticketDoc.reopenDeadline ?? null, liveChat, messages: messages .sort((a, b) => a.createdAt - b.createdAt) .map((message) => ({ id: message._id, body: message.body, createdAt: message.createdAt, updatedAt: message.updatedAt, authorId: String(message.authorId), authorName: message.authorSnapshot?.name ?? null, authorEmail: message.authorSnapshot?.email ?? null, attachments: (message.attachments ?? []).map((attachment) => ({ storageId: attachment.storageId, name: attachment.name, size: attachment.size ?? null, type: attachment.type ?? null, })), readBy: (message.readBy ?? []).map((entry) => ({ userId: String(entry.userId), readAt: entry.readAt, })), })), } }, }) export const listTicketForms = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.optional(v.id("companies")), }, handler: async (ctx, { tenantId, viewerId, companyId }) => { const viewer = await requireUser(ctx, viewerId, tenantId) const viewerCompanyId = companyId ?? viewer.user.companyId ?? null const viewerRole = (viewer.role ?? "").toUpperCase() const templates = await fetchTemplateSummaries(ctx, tenantId) const defaultTemplate: TemplateSummary = { key: "default", label: "Chamado", description: "Campos adicionais exibidos em chamados gerais ou sem template específico.", defaultEnabled: true, } const templatesWithDefault = [defaultTemplate, ...templates.filter((template) => template.key !== "default")] const scopes = templatesWithDefault.map((template) => template.key) const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId) const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" const settingsByTemplate = staffOverride ? new Map[]>() : await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId) const forms = [] as Array<{ key: string label: string description: string fields: Array<{ id: Id<"ticketFields"> key: string label: string type: string required: boolean description: string options: { value: string; label: string }[] }> }> for (const template of templatesWithDefault) { const templateSettings = settingsByTemplate.get(template.key) ?? [] let enabled = staffOverride ? true : resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, { companyId: viewerCompanyId, userId: viewer.user._id, }) if (!enabled) { continue } const scopedFields = fieldsByScope.get(template.key) ?? [] forms.push({ key: template.key, label: template.label, description: template.description, fields: scopedFields .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((field) => ({ id: field._id, key: field.key, label: field.label, type: field.type, required: Boolean(field.required), description: field.description ?? "", options: field.options ?? [], })), }) } return forms }, }) export const findByReference = query({ args: { tenantId: v.string(), viewerId: v.id("users"), reference: v.number(), }, handler: async (ctx, { tenantId, viewerId, reference }) => { const viewer = await requireUser(ctx, viewerId, tenantId) const ticket = await ctx.db .query("tickets") .withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference)) .first() if (!ticket) { return null } const normalizedRole = viewer.role ?? "" if (normalizedRole === "MANAGER") { await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">) } else if (normalizedRole === "COLLABORATOR") { if (String(ticket.requesterId) !== String(viewer.user._id)) { return null } } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { return null } return { id: ticket._id, reference: ticket.reference, subject: ticket.subject, status: ticket.status, } }, }) export const postChatMessage = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), body: v.string(), attachments: v.optional( v.array( v.object({ storageId: v.id("_storage"), name: v.string(), size: v.optional(v.number()), type: v.optional(v.string()), }) ) ), }, handler: async (ctx, { ticketId, actorId, body, attachments }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> if (!ticketDoc.chatEnabled) { throw new ConvexError("Chat não habilitado para este chamado") } const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc) const now = Date.now() if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") { throw new ConvexError("O chat deste chamado está encerrado") } const trimmedBody = body.replace(/\r\n/g, "\n").trim() const files = attachments ?? [] // Validar que há pelo menos texto ou anexo if (trimmedBody.length === 0 && files.length === 0) { throw new ConvexError("Digite uma mensagem ou anexe um arquivo") } if (trimmedBody.length > 4000) { throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)") } // Validar anexos if (files.length > 5) { throw new ConvexError("Envie até 5 arquivos por mensagem") } const maxAttachmentSize = 5 * 1024 * 1024 for (const file of files) { if (typeof file.size === "number" && file.size > maxAttachmentSize) { throw new ConvexError("Cada arquivo pode ter até 5MB") } } // Normalizar corpo apenas se houver texto let normalizedBody = "" if (trimmedBody.length > 0) { normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId) const plainLength = plainTextLength(normalizedBody) if (plainLength > 4000) { throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)") } } const authorSnapshot: CommentAuthorSnapshot = { name: participant.user.name, email: participant.user.email, avatarUrl: participant.user.avatarUrl ?? undefined, teams: participant.user.teams ?? undefined, } const messageId = await ctx.db.insert("ticketChatMessages", { ticketId, tenantId: ticketDoc.tenantId, companyId: ticketDoc.companyId ?? undefined, authorId: actorId, authorSnapshot, body: normalizedBody, attachments: files, notifiedAt: undefined, createdAt: now, updatedAt: now, readBy: [{ userId: actorId, readAt: now }], }) await ctx.db.insert("ticketEvents", { ticketId, type: "CHAT_MESSAGE_ADDED", payload: { messageId, authorId: actorId, authorName: participant.user.name, actorRole: participant.role ?? null, }, createdAt: now, }) await ctx.db.patch(ticketId, { updatedAt: now }) // Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa // IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions // O Convex faz retry automatico em caso de OCC conflict const actorRole = participant.role?.toUpperCase() ?? "" if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) { const activeSession = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .filter((q) => q.eq(q.field("status"), "ACTIVE")) .first() if (activeSession) { // Refetch para garantir valor mais recente (OCC protection) const freshSession = await ctx.db.get(activeSession._id) if (freshSession) { await ctx.db.patch(activeSession._id, { unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1, lastActivityAt: now, lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente }) } } } return { ok: true, messageId } }, }) export const markChatRead = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), messageIds: v.array(v.id("ticketChatMessages")), }, handler: async (ctx, { ticketId, actorId, messageIds }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> await requireTicketChatParticipant(ctx, actorId, ticketDoc) const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id)))) const now = Date.now() for (const id of uniqueIds) { const message = await ctx.db.get(id as Id<"ticketChatMessages">) if (!message || String(message.ticketId) !== String(ticketId)) { continue } const readBy = new Map; readAt: number }>() for (const entry of message.readBy ?? []) { readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt }) } readBy.set(String(actorId), { userId: actorId, readAt: now }) await ctx.db.patch(id as Id<"ticketChatMessages">, { readBy: Array.from(readBy.values()), updatedAt: now, }) } // Zerar contador de nao lidas pelo agente na sessao ativa const session = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .filter((q) => q.eq(q.field("status"), "ACTIVE")) .first() if (session) { await ctx.db.patch(session._id, { unreadByAgent: 0 }) } return { ok: true } }, }) export const ensureTicketFormDefaults = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), }, handler: async (ctx, { tenantId, actorId }) => { await requireUser(ctx, actorId, tenantId); await ensureTicketFormDefaultsForTenant(ctx, tenantId); return { ok: true }; }, }); export async function submitCsatHandler( ctx: MutationCtx, { ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null } ) { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const normalizedStatus = normalizeStatus(ticket.status) if (normalizedStatus !== "RESOLVED") { throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado") } const viewer = await requireUser(ctx, actorId, ticket.tenantId) const normalizedRole = (viewer.role ?? "").toUpperCase() if (normalizedRole !== "COLLABORATOR") { throw new ConvexError("Somente o solicitante pode avaliar o chamado") } const viewerEmail = viewer.user.email.trim().toLowerCase() const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null const isOwnerById = String(ticket.requesterId) === String(viewer.user._id) const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false if (!isOwnerById && !isOwnerByEmail) { throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado") } if (typeof ticket.csatScore === "number") { throw new ConvexError("Este chamado já possui uma avaliação registrada") } if (!Number.isFinite(score)) { throw new ConvexError("Pontuação inválida") } const resolvedMaxScore = Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5 const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score))) const normalizedComment = typeof comment === "string" ? comment .replace(/\r\n/g, "\n") .split("\n") .map((line) => line.trim()) .join("\n") .trim() : "" if (normalizedComment.length > 2000) { throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)") } const now = Date.now() let csatAssigneeId: Id<"users"> | undefined let csatAssigneeSnapshot: | { name: string email?: string avatarUrl?: string teams?: string[] } | undefined if (ticket.assigneeId) { const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null if (assigneeDoc) { csatAssigneeId = assigneeDoc._id csatAssigneeSnapshot = { name: assigneeDoc.name, email: assigneeDoc.email, avatarUrl: assigneeDoc.avatarUrl ?? undefined, teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined, } } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") { const snapshot = ticket.assigneeSnapshot as { name?: string email?: string avatarUrl?: string teams?: string[] } if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) { csatAssigneeId = ticket.assigneeId csatAssigneeSnapshot = { name: snapshot.name, email: snapshot.email ?? undefined, avatarUrl: snapshot.avatarUrl ?? undefined, teams: snapshot.teams ?? undefined, } } } } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") { const snapshot = ticket.assigneeSnapshot as { name?: string email?: string avatarUrl?: string teams?: string[] } if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) { csatAssigneeSnapshot = { name: snapshot.name, email: snapshot.email ?? undefined, avatarUrl: snapshot.avatarUrl ?? undefined, teams: snapshot.teams ?? undefined, } } } await ctx.db.patch(ticketId, { csatScore: normalizedScore, csatMaxScore: resolvedMaxScore, csatComment: normalizedComment.length > 0 ? normalizedComment : undefined, csatRatedAt: now, csatRatedBy: actorId, csatAssigneeId, csatAssigneeSnapshot, }) await ctx.db.insert("ticketEvents", { ticketId, type: "CSAT_RATED", payload: { score: normalizedScore, maxScore: resolvedMaxScore, comment: normalizedComment.length > 0 ? normalizedComment : undefined, ratedBy: actorId, assigneeId: csatAssigneeId ?? null, assigneeName: csatAssigneeSnapshot?.name ?? null, }, createdAt: now, }) return { ok: true, score: normalizedScore, maxScore: resolvedMaxScore, comment: normalizedComment.length > 0 ? normalizedComment : null, ratedAt: now, } } export const submitCsat = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), score: v.number(), maxScore: v.optional(v.number()), comment: v.optional(v.string()), }, handler: submitCsatHandler, }) export const changeRequester = mutation({ args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") }, handler: async (ctx, { ticketId, requesterId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const viewerRole = (viewer.role ?? "AGENT").toUpperCase() const actor = viewer.user if (String(ticketDoc.requesterId) === String(requesterId)) { return { status: "unchanged" } } const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null if (!requester || requester.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Solicitante inválido") } if (viewerRole === "MANAGER") { if (!actor.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } if (requester.companyId !== actor.companyId) { throw new ConvexError("Gestores só podem alterar para usuários da própria empresa") } } const now = Date.now() const requesterSnapshot = { name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } let companyId: Id<"companies"> | undefined let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined if (requester.companyId) { const company = await ctx.db.get(requester.companyId) if (company) { companyId = company._id as Id<"companies"> companySnapshot = { name: company.name, slug: company.slug ?? undefined, isAvulso: company.isAvulso ?? undefined, } } } const patch: Record = { requesterId, requesterSnapshot, updatedAt: now, } if (companyId) { patch["companyId"] = companyId patch["companySnapshot"] = companySnapshot } else { patch["companyId"] = undefined patch["companySnapshot"] = undefined } await ctx.db.patch(ticketId, patch) await ctx.db.insert("ticketEvents", { ticketId, type: "REQUESTER_CHANGED", payload: { requesterId, requesterName: requester.name, requesterEmail: requester.email, companyId: companyId ?? null, companyName: companySnapshot?.name ?? null, actorId, actorName: actor.name, actorAvatar: actor.avatarUrl, }, createdAt: now, }) return { status: "updated" } }, }) export const purgeTicketsForUsers = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), userIds: v.array(v.id("users")), }, handler: async (ctx, { tenantId, actorId, userIds }) => { await requireAdmin(ctx, actorId, tenantId) if (userIds.length === 0) { return { deleted: 0 } } const uniqueIds = Array.from(new Set(userIds.map((id) => id))) let deleted = 0 const MAX_BATCH = 100 // Limita para evitar OOM em tenants grandes for (const userId of uniqueIds) { // Processa em batches para evitar carregar todos na memoria let hasMore = true while (hasMore) { const requesterTickets = await ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", userId)) .take(MAX_BATCH) hasMore = requesterTickets.length === MAX_BATCH for (const ticket of requesterTickets) { await ctx.db.delete(ticket._id) deleted += 1 } } hasMore = true while (hasMore) { const assigneeTickets = await ctx.db .query("tickets") .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", tenantId).eq("assigneeId", userId)) .take(MAX_BATCH) hasMore = assigneeTickets.length === MAX_BATCH for (const ticket of assigneeTickets) { await ctx.db.delete(ticket._id) deleted += 1 } } } return { deleted } }, }) export const updateVisitSchedule = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), visitDate: v.number(), }, handler: async (ctx, { ticketId, actorId, visitDate }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) if (viewer.role === "MANAGER") { throw new ConvexError("Gestores não podem alterar a data da visita") } if (!Number.isFinite(visitDate)) { throw new ConvexError("Data da visita inválida") } if (!ticketDoc.queueId) { throw new ConvexError("Este ticket não possui fila configurada") } const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null if (!queue) { throw new ConvexError("Fila não encontrada para este ticket") } const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase() const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword)) if (!isVisitQueue) { throw new ConvexError("Somente tickets da fila de visitas possuem data de visita") } const now = Date.now() const previousVisitDate = typeof ticketDoc.dueAt === "number" ? ticketDoc.dueAt : null const actor = viewer.user await ctx.db.patch(ticketId, { dueAt: visitDate, visitStatus: "scheduled", visitPerformedAt: undefined, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "VISIT_SCHEDULE_CHANGED", payload: { visitDate, previousVisitDate, actorId, actorName: actor.name, actorAvatar: actor.avatarUrl ?? undefined, }, createdAt: now, }) return { status: "updated" } }, }) export const updateVisitStatus = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), status: v.string(), performedAt: v.optional(v.number()), }, handler: async (ctx, { ticketId, actorId, status, performedAt }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) if (viewer.role === "MANAGER") { throw new ConvexError("Gestores não podem alterar o status da visita") } const normalizedStatus = (status ?? "").toLowerCase() if (!VISIT_STATUSES.has(normalizedStatus)) { throw new ConvexError("Status da visita inválido") } if (!ticketDoc.queueId) { throw new ConvexError("Este ticket não possui fila configurada") } const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null if (!queue) { throw new ConvexError("Fila não encontrada para este ticket") } const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase() const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword)) if (!isVisitQueue) { throw new ConvexError("Somente tickets da fila de visitas possuem status de visita") } const now = Date.now() const completed = VISIT_COMPLETED_STATUSES.has(normalizedStatus) const resolvedPerformedAt = completed && typeof performedAt === "number" && Number.isFinite(performedAt) ? performedAt : completed ? now : undefined await ctx.db.patch(ticketId, { visitStatus: normalizedStatus, visitPerformedAt: completed ? resolvedPerformedAt : undefined, // Mantemos dueAt para não perder o agendamento original. dueAt: ticketDoc.dueAt, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "VISIT_STATUS_CHANGED", payload: { status: normalizedStatus, performedAt: resolvedPerformedAt, actorId, actorName: viewer.user.name, actorAvatar: viewer.user.avatarUrl ?? undefined, }, createdAt: now, }) return { status: normalizedStatus } }, }) export const changeQueue = mutation({ args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") }, handler: async (ctx, { ticketId, queueId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) if (viewer.role === "MANAGER") { throw new ConvexError("Gestores não podem alterar a fila do chamado") } const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null if (!queue || queue.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Fila inválida") } const now = Date.now() const queueName = normalizeQueueName(queue) const normalizedQueueLabel = (queueName ?? queue.name ?? "").toLowerCase() const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword)) const patch: Partial> = { queueId, updatedAt: now } if (!isVisitQueueTarget) { patch.dueAt = ticketDoc.slaSolutionDueAt ?? undefined } await ctx.db.patch(ticketId, patch) await ctx.db.insert("ticketEvents", { ticketId, type: "QUEUE_CHANGED", payload: { queueId, queueName, actorId }, createdAt: now, }) await runTicketAutomationsForEvent(ctx, { tenantId: ticketDoc.tenantId, ticketId, eventType: "QUEUE_CHANGED", }) }, }); export const updateCategories = mutation({ args: { ticketId: v.id("tickets"), categoryId: v.union(v.id("ticketCategories"), v.null()), subcategoryId: v.union(v.id("ticketSubcategories"), v.null()), actorId: v.id("users"), }, handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) if (viewer.role === "MANAGER") { throw new ConvexError("Gestores não podem alterar a categorização do chamado") } if (categoryId === null) { if (subcategoryId !== null) { throw new ConvexError("Subcategoria inválida") } if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) { return { status: "unchanged" } } const now = Date.now() await ctx.db.patch(ticketId, { categoryId: undefined, subcategoryId: undefined, updatedAt: now, }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null await ctx.db.insert("ticketEvents", { ticketId, type: "CATEGORY_CHANGED", payload: { categoryId: null, categoryName: null, subcategoryId: null, subcategoryName: null, actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, }, createdAt: now, }) return { status: "cleared" } } const category = await ctx.db.get(categoryId) if (!category || category.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Categoria inválida") } let subcategoryName: string | null = null if (subcategoryId !== null) { const subcategory = await ctx.db.get(subcategoryId) if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Subcategoria inválida") } subcategoryName = subcategory.name } if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) { return { status: "unchanged" } } const now = Date.now() await ctx.db.patch(ticketId, { categoryId, subcategoryId: subcategoryId ?? undefined, updatedAt: now, }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null await ctx.db.insert("ticketEvents", { ticketId, type: "CATEGORY_CHANGED", payload: { categoryId, categoryName: category.name, subcategoryId, subcategoryName, actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, }, createdAt: now, }) return { status: "updated" } }, }) export const workSummary = query({ args: { ticketId: v.id("tickets"), viewerId: v.id("users") }, handler: async (ctx, { ticketId, viewerId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) return null await requireStaff(ctx, viewerId, ticket.tenantId) const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null const serverNow = Date.now() const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow) return { ticketId, totalWorkedMs: ticket.totalWorkedMs ?? 0, internalWorkedMs: ticket.internalWorkedMs ?? 0, externalWorkedMs: ticket.externalWorkedMs ?? 0, serverNow, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: activeSession.workType ?? "INTERNAL", } : null, perAgentTotals: perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName, agentEmail: item.agentEmail, avatarUrl: item.avatarUrl, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })), } }, }) export const updatePriority = mutation({ args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, priority, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } await requireStaff(ctx, actorId, ticket.tenantId) const now = Date.now(); await ctx.db.patch(ticketId, { priority, updatedAt: now }); const pt: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" }; await ctx.db.insert("ticketEvents", { ticketId, type: "PRIORITY_CHANGED", payload: { to: priority, toLabel: pt[priority] ?? priority, actorId }, createdAt: now, }); await runTicketAutomationsForEvent(ctx, { tenantId: ticket.tenantId, ticketId, eventType: "PRIORITY_CHANGED", }) }, }); export const startWork = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) }, handler: async (ctx, { ticketId, actorId, workType }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const currentAssigneeId = ticketDoc.assigneeId ?? null const now = Date.now() if (!currentAssigneeId) { throw new ConvexError("Defina um responsável antes de iniciar o atendimento") } if (ticketDoc.activeSessionId) { const session = await ctx.db.get(ticketDoc.activeSessionId) return { status: "already_started", sessionId: ticketDoc.activeSessionId, startedAt: session?.startedAt ?? now, serverNow: now, } } const sessionId = await ctx.db.insert("ticketWorkSessions", { ticketId, agentId: currentAssigneeId, workType: (workType ?? "INTERNAL").toUpperCase(), startedAt: now, }) const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now); await ctx.db.patch(ticketId, { working: true, activeSessionId: sessionId, status: "AWAITING_ATTENDANCE", updatedAt: now, ...slaStartPatch, }) await ctx.db.insert("ticketEvents", { ticketId, type: "WORK_STARTED", payload: { actorId, actorName: viewer.user.name, actorAvatar: viewer.user.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase(), }, createdAt: now, }) return { status: "started", sessionId, startedAt: now, serverNow: now } }, }) export const pauseWork = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), reason: v.string(), note: v.optional(v.string()), }, handler: async (ctx, { ticketId, actorId, reason, note }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const isAdmin = viewer.role === "ADMIN" if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) { throw new ConvexError("Somente o responsável atual pode pausar este chamado") } if (!ticketDoc.activeSessionId) { const normalizedStatus = normalizeStatus(ticketDoc.status) if (normalizedStatus === "AWAITING_ATTENDANCE") { const now = Date.now() const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) await ctx.db.patch(ticketId, { status: "PAUSED", working: false, updatedAt: now, ...slaPausePatch, }) await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: "PAUSED", toLabel: STATUS_LABELS.PAUSED, actorId, }, createdAt: now, }) return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now } } return { status: "already_paused" } } if (!PAUSE_REASON_LABELS[reason]) { throw new ConvexError("Motivo de pausa inválido") } const session = await ctx.db.get(ticketDoc.activeSessionId) if (!session) { await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false }) return { status: "session_missing" } } const now = Date.now() const durationMs = now - session.startedAt await ctx.db.patch(ticketDoc.activeSessionId, { stoppedAt: now, durationMs, pauseReason: reason, pauseNote: note ?? "", }) const sessionType = (session.workType ?? "INTERNAL").toUpperCase() const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, status: "PAUSED", totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, updatedAt: now, ...slaPausePatch, }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null await ctx.db.insert("ticketEvents", { ticketId, type: "WORK_PAUSED", payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId: session._id, sessionDurationMs: durationMs, workType: sessionType, pauseReason: reason, pauseReasonLabel: PAUSE_REASON_LABELS[reason], pauseNote: note ?? "", }, createdAt: now, }) return { status: "paused", durationMs, pauseReason: reason, pauseNote: note ?? "", serverNow: now, } }, }) export const pauseInternalSessionsForLunch = mutation({ args: {}, handler: async (ctx) => { const now = Date.now() const { hour } = getHourMinuteInTimezone(now, LUNCH_BREAK_TIMEZONE) if (hour !== LUNCH_BREAK_HOUR) { return { skipped: true, reason: "outside_lunch_window" as const } } // Limita a 200 sessoes por execucao para evitar OOM // Se houver mais, o proximo cron pegara o restante const activeSessions = await ctx.db .query("ticketWorkSessions") .filter((q) => q.eq(q.field("stoppedAt"), undefined)) .take(200) let paused = 0 for (const sessionDoc of activeSessions) { const session = sessionDoc as Doc<"ticketWorkSessions"> const workType = (session.workType ?? "INTERNAL").toUpperCase() if (workType !== "INTERNAL") continue const ticket = (await ctx.db.get(session.ticketId)) as Doc<"tickets"> | null if (!ticket || ticket.activeSessionId !== session._id) { continue } await pauseSessionForLunch(ctx, ticket, session) paused += 1 } return { skipped: false, paused } }, }) export const adjustWorkSummary = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), internalWorkedMs: v.number(), externalWorkedMs: v.number(), reason: v.string(), }, handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const normalizedRole = (viewer.role ?? "").toUpperCase() if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.") } if (ticketDoc.activeSessionId) { throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.") } const trimmedReason = reason.trim() if (trimmedReason.length < 5) { throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.") } if (trimmedReason.length > 1000) { throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).") } const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0) const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0) const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal) const nextInternal = Math.max(0, Math.round(internalWorkedMs)) const nextExternal = Math.max(0, Math.round(externalWorkedMs)) const nextTotal = nextInternal + nextExternal const deltaInternal = nextInternal - previousInternal const deltaExternal = nextExternal - previousExternal const deltaTotal = nextTotal - previousTotal const now = Date.now() await ctx.db.patch(ticketId, { internalWorkedMs: nextInternal, externalWorkedMs: nextExternal, totalWorkedMs: nextTotal, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "WORK_ADJUSTED", payload: { actorId, actorName: viewer.user.name, actorAvatar: viewer.user.avatarUrl, previousInternalMs: previousInternal, previousExternalMs: previousExternal, previousTotalMs: previousTotal, nextInternalMs: nextInternal, nextExternalMs: nextExternal, nextTotalMs: nextTotal, deltaInternalMs: deltaInternal, deltaExternalMs: deltaExternal, deltaTotalMs: deltaTotal, }, createdAt: now, }) const bodyHtml = [ "

Ajuste manual de horas

", "
    ", `
  • Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} → ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})
  • `, `
  • Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} → ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})
  • `, `
  • Total: ${escapeHtml(formatWorkDuration(previousTotal))} → ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})
  • `, "
", `

Motivo: ${escapeHtml(trimmedReason)}

`, ].join("") const authorSnapshot: CommentAuthorSnapshot = { name: viewer.user.name, email: viewer.user.email, avatarUrl: viewer.user.avatarUrl ?? undefined, teams: viewer.user.teams ?? undefined, } await ctx.db.insert("ticketComments", { ticketId, authorId: actorId, visibility: "INTERNAL", body: bodyHtml, authorSnapshot, attachments: [], createdAt: now, updatedAt: now, }) const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now) return { ticketId, totalWorkedMs: nextTotal, internalWorkedMs: nextInternal, externalWorkedMs: nextExternal, serverNow: now, perAgentTotals: perAgentTotals.map((item) => ({ agentId: item.agentId, agentName: item.agentName, agentEmail: item.agentEmail, avatarUrl: item.avatarUrl, totalWorkedMs: item.totalWorkedMs, internalWorkedMs: item.internalWorkedMs, externalWorkedMs: item.externalWorkedMs, })), } }, }) export const updateSubject = mutation({ args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, subject, actorId }) => { const now = Date.now(); const t = await ctx.db.get(ticketId); if (!t) { throw new ConvexError("Ticket não encontrado") } await requireStaff(ctx, actorId, t.tenantId) const trimmed = subject.trim(); if (trimmed.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); } await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now }); const actor = await ctx.db.get(actorId); await ctx.db.insert("ticketEvents", { ticketId, type: "SUBJECT_CHANGED", payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl }, createdAt: now, }); }, }); export const updateSummary = mutation({ args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") }, handler: async (ctx, { ticketId, summary, actorId }) => { const now = Date.now(); const t = await ctx.db.get(ticketId); if (!t) { throw new ConvexError("Ticket não encontrado") } await requireStaff(ctx, actorId, t.tenantId) if (summary && summary.trim().length > MAX_SUMMARY_CHARS) { throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`) } await ctx.db.patch(ticketId, { summary, updatedAt: now }); const actor = await ctx.db.get(actorId); await ctx.db.insert("ticketEvents", { ticketId, type: "SUMMARY_CHANGED", payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl }, createdAt: now, }); }, }); export const updateCustomFields = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users"), fields: v.array( v.object({ fieldId: v.id("ticketFields"), value: v.any(), }) ), }, handler: async (ctx, { ticketId, actorId, fields }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) const normalizedRole = (viewer.role ?? "").toUpperCase() if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.") } const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? [] const previousRecord = mapCustomFieldsToRecord(previousEntries) const sanitizedInputs: CustomFieldInput[] = fields .filter((entry) => entry.value !== undefined) .map((entry) => ({ fieldId: entry.fieldId, value: entry.value, })) const normalized = await normalizeCustomFieldValues( ctx, ticketDoc.tenantId, sanitizedInputs, ticketDoc.formTemplate ?? null ) const nextRecord = mapCustomFieldsToRecord(normalized) const metaByFieldKey = new Map< string, { fieldId?: Id<"ticketFields">; label: string; type: string } >() for (const entry of previousEntries) { metaByFieldKey.set(entry.fieldKey, { fieldId: entry.fieldId, label: entry.label, type: entry.type, }) } for (const entry of normalized) { metaByFieldKey.set(entry.fieldKey, { fieldId: entry.fieldId, label: entry.label, type: entry.type, }) } const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)] const changedKeys = Array.from(new Set(keyOrder)).filter((key) => { const previousEntry = getCustomFieldRecordEntry(previousRecord, key) const nextEntry = getCustomFieldRecordEntry(nextRecord, key) return !areCustomFieldEntriesEqual(previousEntry, nextEntry) }) if (changedKeys.length === 0) { return { customFields: previousRecord, updatedAt: ticketDoc.updatedAt ?? Date.now(), } } const now = Date.now() await ctx.db.patch(ticketId, { customFields: normalized.length > 0 ? normalized : undefined, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "CUSTOM_FIELDS_UPDATED", payload: { actorId, actorName: viewer.user.name, actorAvatar: viewer.user.avatarUrl ?? undefined, fields: changedKeys.map((fieldKey) => { const meta = metaByFieldKey.get(fieldKey) const previous = getCustomFieldRecordEntry(previousRecord, fieldKey) const next = getCustomFieldRecordEntry(nextRecord, fieldKey) return { fieldId: meta?.fieldId, fieldKey, label: meta?.label ?? fieldKey, type: meta?.type ?? "text", previousValue: previous?.value ?? null, nextValue: next?.value ?? null, previousDisplayValue: previous?.displayValue ?? null, nextDisplayValue: next?.displayValue ?? null, changeType: !previous ? "added" : !next ? "removed" : "updated", } }), }, createdAt: now, }) return { customFields: nextRecord, updatedAt: now, } }, }) export const playNext = mutation({ args: { tenantId: v.string(), queueId: v.optional(v.id("queues")), agentId: v.id("users"), }, handler: async (ctx, { tenantId, queueId, agentId }) => { const { user: agent } = await requireStaff(ctx, agentId, tenantId) // Find eligible tickets: not resolved/closed and not assigned // Limita busca a 500 tickets mais antigos (createdAt asc) para evitar OOM // Isso garante que pegamos os tickets mais antigos primeiro let candidates: Doc<"tickets">[] = [] if (queueId) { candidates = await ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) .take(500) } else { candidates = await ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(500) } candidates = candidates.filter( (t) => t.status !== "RESOLVED" && !t.assigneeId ); if (candidates.length === 0) return null; // prioritize by priority then createdAt const rank: Record = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } candidates.sort((a, b) => { const pa = rank[a.priority] ?? 999 const pb = rank[b.priority] ?? 999 if (pa !== pb) return pa - pb return a.createdAt - b.createdAt }) const chosen = candidates[0]; const now = Date.now(); const currentStatus = normalizeStatus(chosen.status); const nextStatus: TicketStatusNormalized = currentStatus; 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, working: false, activeSessionId: undefined, updatedAt: now, }); await ctx.db.insert("ticketEvents", { ticketId: chosen._id, type: "ASSIGNEE_CHANGED", payload: { assigneeId: agentId, assigneeName: agent.name }, createdAt: now, }); // hydrate minimal public ticket like in list const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null const queueName = normalizeQueueName(queue) return { id: chosen._id, reference: chosen.reference, tenantId: chosen.tenantId, subject: chosen.subject, summary: chosen.summary, status: nextStatus, priority: chosen.priority, channel: chosen.channel, queue: queueName, 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, firstResponseAt: chosen.firstResponseAt ?? null, resolvedAt: chosen.resolvedAt ?? null, updatedAt: chosen.updatedAt, createdAt: chosen.createdAt, tags: chosen.tags ?? [], lastTimelineEntry: null, metrics: null, } }, }); export const remove = mutation({ args: { ticketId: v.id("tickets"), actorId: v.id("users") }, handler: async (ctx, { ticketId, actorId }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } await requireAdmin(ctx, actorId, ticket.tenantId) // delete comments (and attachments) em batches para evitar OOM const BATCH_SIZE = 100 let hasMoreComments = true while (hasMoreComments) { const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .take(BATCH_SIZE); hasMoreComments = comments.length === BATCH_SIZE for (const c of comments) { for (const att of c.attachments ?? []) { try { await ctx.storage.delete(att.storageId); } catch {} } await ctx.db.delete(c._id); } } // delete events em batches let hasMoreEvents = true while (hasMoreEvents) { const events = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .take(BATCH_SIZE); hasMoreEvents = events.length === BATCH_SIZE for (const ev of events) await ctx.db.delete(ev._id); } // delete ticket await ctx.db.delete(ticketId); // (optional) event is moot after deletion return true; }, }); export const reassignTicketsByEmail = mutation({ args: { tenantId: v.string(), actorId: v.id("users"), fromEmail: v.string(), toUserId: v.id("users"), dryRun: v.optional(v.boolean()), limit: v.optional(v.number()), updateSnapshot: v.optional(v.boolean()), }, handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => { await requireAdmin(ctx, actorId, tenantId) const normalizedFrom = fromEmail.trim().toLowerCase() if (!normalizedFrom || !normalizedFrom.includes("@")) { throw new ConvexError("E-mail de origem inválido") } const toUser = await ctx.db.get(toUserId) if (!toUser || toUser.tenantId !== tenantId) { throw new ConvexError("Usuário de destino inválido para o tenant") } // Coletar tickets por requesterId (quando possível via usuário antigo) const fromUser = await ctx.db .query("users") .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom)) .first() // Limita a 1000 tickets por requesterId para evitar OOM const byRequesterId: Doc<"tickets">[] = fromUser ? await ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id)) .take(1000) : [] // Buscar tickets por snapshot de email (limitado a 2000 para evitar OOM) // Se houver mais, o usuario pode rodar novamente const allTenant = await ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .take(2000) const bySnapshotEmail = allTenant.filter((t) => { const rs = t.requesterSnapshot as { email?: string } | undefined const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null if (!email || email !== normalizedFrom) return false // Evita duplicar os já coletados por requesterId if (fromUser && t.requesterId === fromUser._id) return false return true }) const candidatesMap = new Map>() for (const t of byRequesterId) candidatesMap.set(String(t._id), t) for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t) const candidates = Array.from(candidatesMap.values()) const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length)) const toProcess = candidates.slice(0, maxToProcess) if (dryRun) { return { dryRun: true as const, fromEmail: normalizedFrom, toUserId, candidates: candidates.length, willUpdate: toProcess.length, } } const now = Date.now() let updated = 0 for (const t of toProcess) { const patch: Record = { requesterId: toUserId, updatedAt: now } if (updateSnapshot) { patch.requesterSnapshot = { name: toUser.name, email: toUser.email, avatarUrl: toUser.avatarUrl ?? undefined, teams: toUser.teams ?? undefined, } } await ctx.db.patch(t._id, patch) await ctx.db.insert("ticketEvents", { ticketId: t._id, type: "REQUESTER_CHANGED", payload: { fromUserId: fromUser?._id ?? null, fromEmail: normalizedFrom, toUserId, toUserName: toUser.name, }, createdAt: now, }) updated += 1 } return { dryRun: false as const, fromEmail: normalizedFrom, toUserId, candidates: candidates.length, updated, } }, }) /** * Query paginada para listagem de tickets. * Utiliza a paginação nativa do Convex para evitar carregar todos os tickets de uma vez. * Filtros são aplicados no servidor para melhor performance. */ export const listPaginated = query({ args: { viewerId: v.optional(v.id("users")), tenantId: v.string(), status: v.optional(v.string()), priority: v.optional(v.union(v.string(), v.array(v.string()))), channel: v.optional(v.string()), queueId: v.optional(v.id("queues")), assigneeId: v.optional(v.id("users")), requesterId: v.optional(v.id("users")), search: v.optional(v.string()), paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { if (!args.viewerId) { return { page: [], isDone: true, continueCursor: "" }; } const viewerId = args.viewerId as Id<"users">; const { user, role } = await requireUser(ctx, viewerId, args.tenantId); if (role === "MANAGER" && !user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada"); } const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; const normalizedPriorityFilter = normalizePriorityFilter(args.priority); const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; const searchTerm = args.search?.trim().toLowerCase() ?? null; // Constrói a query base com índice apropriado let baseQuery; if (role === "MANAGER") { baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)); } else if (args.assigneeId) { baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)); } else if (args.requesterId) { baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)); } else if (args.queueId) { baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)); } else if (normalizedStatusFilter) { baseQuery = ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)); } else { baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); } // Executa a paginação const paginationResult = await baseQuery.order("desc").paginate(args.paginationOpts); // Aplica filtros que não puderam ser feitos via índice let filtered = paginationResult.page; if (role === "MANAGER") { filtered = filtered.filter((t) => t.companyId === user.companyId); } if (prioritySet) { filtered = filtered.filter((t) => prioritySet.has(t.priority)); } if (normalizedChannelFilter) { filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); } if (args.assigneeId && !args.assigneeId) { filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); } if (args.requesterId && !args.requesterId) { filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); } if (normalizedStatusFilter && !args.queueId && !args.assigneeId && !args.requesterId && role !== "MANAGER") { filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); } if (searchTerm) { filtered = filtered.filter( (t) => t.subject.toLowerCase().includes(searchTerm) || t.summary?.toLowerCase().includes(searchTerm) || `#${t.reference}`.toLowerCase().includes(searchTerm) ); } // Enriquece os dados dos tickets if (filtered.length === 0) { return { page: [], isDone: paginationResult.isDone, continueCursor: paginationResult.continueCursor, }; } const [ requesterDocs, assigneeDocs, queueDocs, companyDocs, machineDocs, activeSessionDocs, categoryDocs, subcategoryDocs, ] = await Promise.all([ loadDocs(ctx, filtered.map((t) => t.requesterId)), loadDocs(ctx, filtered.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.queueId as Id<"queues"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.companyId as Id<"companies"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.machineId as Id<"machines"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)), loadDocs(ctx, filtered.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)), ]); const serverNow = Date.now(); const page = filtered.map((t) => { const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined; const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null; const requesterSummary = requesterDoc ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id }) : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id }); const assigneeDoc = t.assigneeId ? assigneeDocs.get(String(t.assigneeId)) ?? null : null; const assigneeSummary = t.assigneeId ? assigneeDoc ? { id: assigneeDoc._id, name: assigneeDoc.name, email: assigneeDoc.email, avatarUrl: assigneeDoc.avatarUrl, teams: normalizeTeams(assigneeDoc.teams), } : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) : null; const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null; const queueName = normalizeQueueName(queueDoc); const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null; const companySummary = companyDoc ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false } : t.companyId || t.companySnapshot ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) : null; const machineSnapshot = t.machineSnapshot as | { hostname?: string; persona?: string; assignedUserName?: string; assignedUserEmail?: string; status?: string } | undefined; const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null; let machineSummary: { id: Id<"machines"> | null; hostname: string | null; persona: string | null; assignedUserName: string | null; assignedUserEmail: string | null; status: string | null; } | null = null; if (t.machineId) { machineSummary = { id: t.machineId, hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, status: machineDoc?.status ?? machineSnapshot?.status ?? null, }; } else if (machineSnapshot) { machineSummary = { id: null, hostname: machineSnapshot.hostname ?? null, persona: machineSnapshot.persona ?? null, assignedUserName: machineSnapshot.assignedUserName ?? null, assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, status: machineSnapshot.status ?? null, }; } const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null; const categorySummary = categoryDoc ? { id: categoryDoc._id, name: categoryDoc.name } : null; const subcategoryDoc = t.subcategoryId ? subcategoryDocs.get(String(t.subcategoryId)) ?? null : null; const subcategorySummary = subcategoryDoc ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId } : null; const activeSessionDoc = t.activeSessionId ? activeSessionDocs.get(String(t.activeSessionId)) ?? null : null; const activeSession = activeSessionDoc ? { id: activeSessionDoc._id, agentId: activeSessionDoc.agentId, startedAt: activeSessionDoc.startedAt, workType: activeSessionDoc.workType ?? "INTERNAL", } : null; return { id: t._id, reference: t.reference, tenantId: t.tenantId, subject: t.subject, summary: t.summary, status: normalizeStatus(t.status), priority: t.priority, channel: t.channel, queue: queueName, csatScore: typeof t.csatScore === "number" ? t.csatScore : null, csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, csatRatedAt: t.csatRatedAt ?? null, csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, formTemplate: t.formTemplate ?? null, formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), company: companySummary, requester: requesterSummary, assignee: assigneeSummary, slaPolicy: null, dueAt: t.dueAt ?? null, visitStatus: t.visitStatus ?? null, visitPerformedAt: t.visitPerformedAt ?? null, firstResponseAt: t.firstResponseAt ?? null, resolvedAt: t.resolvedAt ?? null, updatedAt: t.updatedAt, createdAt: t.createdAt, tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, category: categorySummary, subcategory: subcategorySummary, machine: machineSummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, internalWorkedMs: t.internalWorkedMs ?? 0, externalWorkedMs: t.externalWorkedMs ?? 0, serverNow, activeSession, }, }; }); return { page, isDone: paginationResult.isDone, continueCursor: paginationResult.continueCursor, }; }, }) // Exporta tickets resolvidos para arquivamento externo (somente com segredo) export const exportForArchive = query({ args: { tenantId: v.string(), before: v.number(), // timestamp ms limit: v.optional(v.number()), secret: v.optional(v.string()), }, handler: async (ctx, args) => { const allowedSecret = process.env["INTERNAL_HEALTH_TOKEN"] ?? process.env["REPORTS_CRON_SECRET"] if (allowedSecret && args.secret !== allowedSecret) { throw new ConvexError("Nao autorizado") } const cutoff = args.before const limit = Math.min(args.limit ?? 50, 200) const candidates = await ctx.db .query("tickets") .withIndex("by_tenant_resolved", (q) => q.eq("tenantId", args.tenantId).lt("resolvedAt", cutoff)) .order("desc") .take(limit) const result: Array<{ ticket: Doc<"tickets"> comments: Array> events: Array> }> = [] for (const t of candidates) { const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", t._id)) .take(200) const events = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", t._id)) .order("desc") .take(200) result.push({ ticket: t, comments, events, }) } return { total: result.length, items: result.map((item) => ({ ticket: item.ticket, comments: item.comments.map((c) => ({ ...c, attachments: (c.attachments ?? []).map((att) => ({ storageId: att.storageId, name: att.name, size: att.size ?? null, type: att.type ?? null, })), })), events: item.events, })), } }, })