// CI touch: enable server-side assignee filtering and trigger redeploy import { mutation, query } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc, type DataModel } from "./_generated/dataModel"; import type { NamedTableInfo, Query as ConvexQuery } from "convex/server"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); const PAUSE_REASON_LABELS: Record = { NO_CONTACT: "Falta de contato", WAITING_THIRD_PARTY: "Aguardando terceiro", IN_PROCEDURE: "Em procedimento", }; 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", }; 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 TICKET_FORM_CONFIG = [ { key: "admissao" as const, label: "Admissão de colaborador", description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.", defaultEnabled: true, }, { key: "desligamento" as const, label: "Desligamento de colaborador", description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.", defaultEnabled: true, }, ]; 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; } } function escapeHtml(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function normalizeFormTemplateKey(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); if (!trimmed) return null; const normalized = trimmed .normalize("NFD") .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .toLowerCase(); return normalized || null; } 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 } export function buildAssigneeChangeComment( reason: string, context: { previousName: string; nextName: string }, ): string { const normalized = reason.replace(/\r\n/g, "\n").trim(); const lines = normalized .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); const previous = escapeHtml(context.previousName || "Não atribuído"); const next = escapeHtml(context.nextName || "Não atribuído"); const reasonHtml = lines.length ? lines.map((line) => `

${escapeHtml(line)}

`).join("") : `

`; return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${reasonHtml}`; } 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)) .collect(); 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") { if (String(ticket.requesterId) !== String(viewer.user._id)) { throw new ConvexError("Apenas o solicitante pode conversar neste chamado"); } return { user: viewer.user, role: normalizedRole, kind: "requester" }; } 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": { if (typeof raw === "number") { if (!Number.isFinite(raw)) { throw new ConvexError(`Data inválida para o campo ${field.label}`); } return { value: raw }; } const parsed = Date.parse(String(raw)); if (!Number.isFinite(parsed)) { throw new ConvexError(`Data inválida para o campo ${field.label}`); } return { value: parsed }; } 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)) .collect(); 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) => { acc[entry.fieldKey] = { label: entry.label, type: entry.type, value: entry.value, displayValue: entry.displayValue, }; return acc; }, {}); } const DEFAULT_TICKETS_LIST_LIMIT = 250; const MIN_TICKETS_LIST_LIMIT = 25; const MAX_TICKETS_LIST_LIMIT = 600; const MAX_FETCH_LIMIT = 1000; const FETCH_MULTIPLIER_NO_SEARCH = 3; const FETCH_MULTIPLIER_WITH_SEARCH = 5; type TicketsTableInfo = NamedTableInfo; type TicketsQueryBuilder = ConvexQuery; 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 multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH; const target = limit * multiplier; return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target)); } 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.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 = args.priority ? args.priority.toUpperCase() : 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)); const applyQueryFilters = (query: TicketsQueryBuilder) => { let working = query; if (normalizedStatusFilter) { working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter)); } if (normalizedPriorityFilter) { working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter)); } if (normalizedChannelFilter) { working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter)); } if (args.queueId) { working = working.filter((q) => q.eq(q.field("queueId"), args.queueId!)); } if (args.assigneeId) { working = working.filter((q) => q.eq(q.field("assigneeId"), args.assigneeId!)); } if (args.requesterId) { working = working.filter((q) => q.eq(q.field("requesterId"), args.requesterId!)); } return working; }; let base: Doc<"tickets">[] = []; if (role === "MANAGER") { const baseQuery = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 = applyQueryFilters( 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 (normalizedPriorityFilter) filtered = filtered.filter((t) => t.priority === normalizedPriorityFilter); 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); const categoryCache = new Map | null>(); const subcategoryCache = new Map | null>(); const machineCache = new Map | null>(); // hydrate requester and assignee const result = await Promise.all( limited.map(async (t) => { const 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 queueName = normalizeQueueName(queue); const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null; if (t.categoryId) { if (!categoryCache.has(t.categoryId)) { categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId)); } const category = categoryCache.get(t.categoryId); if (category) { categorySummary = { id: category._id, name: category.name }; } } if (t.subcategoryId) { if (!subcategoryCache.has(t.subcategoryId)) { subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId)); } const subcategory = subcategoryCache.get(t.subcategoryId); if (subcategory) { subcategorySummary = { id: subcategory._id, name: subcategory.name }; } } 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 cacheKey = String(t.machineId); if (!machineCache.has(cacheKey)) { machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null); } const machineDoc = machineCache.get(cacheKey); 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 serverNow = Date.now() 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 ?? [], 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: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, workType: activeSession.workType ?? "INTERNAL", } : null, }, }; }) ); // 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)) .collect(); 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)) .collect(); 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 ?? [], 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, 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, description: undefined, customFields: customFieldsRecord, timeline: 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")), customFields: v.optional( v.array( v.object({ fieldId: v.id("ticketFields"), value: v.any(), }) ) ), formTemplate: v.optional(v.string()), chatEnabled: v.optional(v.boolean()), }, 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 } const formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? 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 requesterSnapshot = { name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } 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 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)) .collect() const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null if (preferred) { resolvedQueueId = preferred._id as Id<"queues"> } } 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: companyDoc?._id ?? requester.companyId ?? undefined, 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, chatEnabled, working: false, activeSessionId: undefined, totalWorkedMs: 0, createdAt: now, updatedAt: now, firstResponseAt: undefined, resolvedAt: undefined, closedAt: undefined, tags: [], slaPolicyId: undefined, dueAt: undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, }); await ctx.db.insert("ticketEvents", { ticketId: id, type: "CREATED", payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, createdAt: now, }); if (initialAssigneeId && initialAssignee) { await ctx.db.insert("ticketEvents", { ticketId: id, type: "ASSIGNEE_CHANGED", payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId }, createdAt: now, }) } return id; }, }); 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, }); // bump ticket updatedAt await ctx.db.patch(args.ticketId, { updatedAt: now }); 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(); await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now }); await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }); }, }); 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() 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">) 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, }) await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }) 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, }) } 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") } await ctx.db.patch(ticketId, { status: "AWAITING_ATTENDANCE", reopenedAt: now, resolvedAt: undefined, closedAt: undefined, updatedAt: now, }) 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 normalizedStatus = normalizeStatus(ticketDoc.status) if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) { throw new ConvexError("Pause o atendimento antes de reatribuir o chamado") } 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, } await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now }); await ctx.db.insert("ticketEvents", { ticketId, type: "ASSIGNEE_CHANGED", payload: { assigneeId, assigneeName: assignee.name, actorId, previousAssigneeId: currentAssigneeId, previousAssigneeName, reason: normalizedReason.length > 0 ? normalizedReason : undefined, }, createdAt: now, }); if (normalizedReason.length > 0) { const commentBody = buildAssigneeChangeComment(normalizedReason, { previousName: previousAssigneeName, nextName: nextAssigneeName, }) const commentPlainLength = plainTextLength(commentBody) if (commentPlainLength > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } const authorSnapshot: CommentAuthorSnapshot = { name: viewerUser.name, email: viewerUser.email, avatarUrl: viewerUser.avatarUrl ?? undefined, teams: viewerUser.teams ?? undefined, } await ctx.db.insert("ticketComments", { ticketId, authorId: actorId, visibility: "INTERNAL", body: commentBody, authorSnapshot, attachments: [], createdAt: now, updatedAt: now, }) await ctx.db.insert("ticketEvents", { ticketId, type: "COMMENT_ADDED", payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl }, 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) const messages = await ctx.db .query("ticketChatMessages") .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId)) .collect() return { ticketId: String(ticketId), chatEnabled, status, canPost, reopenDeadline: ticketDoc.reopenDeadline ?? null, 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 settings = await ctx.db .query("ticketFormSettings") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() const fieldDefinitions = await ctx.db .query("ticketFields") .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) .collect() const fieldsByScope = new Map[]>() for (const definition of fieldDefinitions) { const scope = (definition.scope ?? "all").trim() if (!fieldsByScope.has(scope)) { fieldsByScope.set(scope, []) } fieldsByScope.get(scope)!.push(definition) } 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 TICKET_FORM_CONFIG) { const enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], { 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() if (trimmedBody.length === 0) { throw new ConvexError("Digite uma mensagem para enviar no chat") } if (trimmedBody.length > 4000) { throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)") } const files = attachments ?? [] 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") } } const normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId) const plainLength = plainTextLength(normalizedBody) if (plainLength === 0) { throw new ConvexError("A mensagem está vazia após a formatação") } 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 }) 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, }) } 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 for (const userId of uniqueIds) { const requesterTickets = await ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", userId)) .collect() for (const ticket of requesterTickets) { await ctx.db.delete(ticket._id) deleted += 1 } const assigneeTickets = await ctx.db .query("tickets") .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", tenantId).eq("assigneeId", userId)) .collect() for (const ticket of assigneeTickets) { await ctx.db.delete(ticket._id) deleted += 1 } } return { deleted } }, }) 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(); await ctx.db.patch(ticketId, { queueId, updatedAt: now }); const queueName = normalizeQueueName(queue); await ctx.db.insert("ticketEvents", { ticketId, type: "QUEUE_CHANGED", payload: { queueId, queueName, actorId }, createdAt: now, }); }, }); 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, }); }, }); 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 isAdmin = viewer.role === "ADMIN" const currentAssigneeId = ticketDoc.assigneeId ?? null const now = Date.now() if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) { throw new ConvexError("Somente o responsável atual pode iniciar este chamado") } if (ticketDoc.activeSessionId) { const session = await ctx.db.get(ticketDoc.activeSessionId) return { status: "already_started", sessionId: ticketDoc.activeSessionId, startedAt: session?.startedAt ?? now, serverNow: now, } } let assigneePatched = false if (!currentAssigneeId) { const assigneeSnapshot = { name: viewer.user.name, email: viewer.user.email, avatarUrl: viewer.user.avatarUrl ?? undefined, teams: viewer.user.teams ?? undefined, } await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now }) ticketDoc.assigneeId = actorId assigneePatched = true } const sessionId = await ctx.db.insert("ticketWorkSessions", { ticketId, agentId: actorId, workType: (workType ?? "INTERNAL").toUpperCase(), startedAt: now, }) await ctx.db.patch(ticketId, { working: true, activeSessionId: sessionId, status: "AWAITING_ATTENDANCE", updatedAt: now, }) if (assigneePatched) { await ctx.db.insert("ticketEvents", { ticketId, type: "ASSIGNEE_CHANGED", payload: { assigneeId: actorId, assigneeName: viewer.user.name, actorId }, createdAt: now, }) } 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() await ctx.db.patch(ticketId, { status: "PAUSED", working: false, updatedAt: now, }) 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 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, }) 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 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 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 let candidates: Doc<"tickets">[] = [] if (queueId) { candidates = await ctx.db .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) .collect() } else { candidates = await ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() } 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) const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .collect(); 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 const events = await ctx.db .query("ticketEvents") .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) .collect(); 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() const byRequesterId: Doc<"tickets">[] = fromUser ? await ctx.db .query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id)) .collect() : [] // Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo const allTenant = await ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() 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, } }, })