sistema-de-chamados/convex/tickets.ts
esdrasrenan 115c5128a6 Consolidate chat timeline events + auto-end inactive sessions
Timeline consolidation:
- Replace multiple LIVE_CHAT_STARTED/ENDED events with single LIVE_CHAT_SUMMARY
- Show total duration accumulated across all sessions
- Display session count (e.g., "23min 15s total - 3 sessoes")
- Show "Ativo" badge when session is active

Auto-end inactive chat sessions:
- Add cron job running every minute to check inactive sessions
- Automatically end sessions after 5 minutes of client inactivity
- Mark auto-ended sessions with "(encerrado por inatividade)" flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 14:44:34 -03:00

4750 lines
159 KiB
TypeScript

// CI touch: enable server-side assignee filtering and trigger redeploy
import { mutation, query } from "./_generated/server";
import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
import { requireAdmin, requireStaff, requireUser } from "./rbac";
import {
OPTIONAL_ADMISSION_FIELD_KEYS,
TICKET_FORM_CONFIG,
TICKET_FORM_DEFAULT_FIELDS,
type TicketFormFieldSeed,
} from "./ticketForms.config";
import {
ensureTicketFormTemplatesForTenant,
getTemplateByKey,
normalizeFormTemplateKey,
} from "./ticketFormTemplates";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
const LUNCH_BREAK_REASON = "LUNCH_BREAK";
const LUNCH_BREAK_NOTE = "Pausa automática do intervalo de almoço";
const LUNCH_BREAK_PAUSE_LABEL = "Intervalo de almoço";
const LUNCH_BREAK_TIMEZONE = "America/Sao_Paulo";
const LUNCH_BREAK_HOUR = 12;
const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento",
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
};
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
};
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "RESOLVED",
};
function normalizePriorityFilter(input: string | string[] | null | undefined): string[] {
if (!input) return [];
const list = Array.isArray(input) ? input : [input];
const set = new Set<string>();
for (const entry of list) {
if (typeof entry !== "string") continue;
const normalized = entry.trim().toUpperCase();
if (!normalized) continue;
set.add(normalized);
}
return Array.from(set);
}
const missingRequesterLogCache = new Set<string>();
const missingCommentAuthorLogCache = new Set<string>();
// Character limits (generous but bounded)
const MAX_SUMMARY_CHARS = 600;
const MAX_COMMENT_CHARS = 20000;
const DEFAULT_REOPEN_DAYS = 7;
const MAX_REOPEN_DAYS = 14;
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
const VISIT_STATUSES = new Set(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"]);
const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]);
type AnyCtx = QueryCtx | MutationCtx;
// Tipos para eventos de chat
type ChatEventPayload = {
sessionId?: string;
agentId?: string;
agentName?: string;
machineHostname?: string;
durationMs?: number;
startedAt?: number;
endedAt?: number;
autoEnded?: boolean;
reason?: string;
};
type TimelineEvent = {
_id: Id<"ticketEvents">;
type: string;
payload?: unknown;
createdAt: number;
};
/**
* Consolida eventos de chat ao vivo na timeline.
* Em vez de mostrar "Chat iniciado" e "Chat finalizado" separadamente,
* mostra um unico evento "LIVE_CHAT_SUMMARY" com a duracao total.
*/
function consolidateChatEventsInTimeline(events: TimelineEvent[]): TimelineEvent[] {
const chatStartEvents: TimelineEvent[] = [];
const chatEndEvents: TimelineEvent[] = [];
const otherEvents: TimelineEvent[] = [];
// Separar eventos de chat dos demais
for (const event of events) {
if (event.type === "LIVE_CHAT_STARTED") {
chatStartEvents.push(event);
} else if (event.type === "LIVE_CHAT_ENDED") {
chatEndEvents.push(event);
} else {
otherEvents.push(event);
}
}
// Se nao houver eventos de chat, retornar como esta
if (chatStartEvents.length === 0 && chatEndEvents.length === 0) {
return events;
}
// Calcular duracao total de todas as sessoes encerradas
let totalDurationMs = 0;
const sessionIds = new Set<string>();
let lastAgentName = "";
let mostRecentActivity = 0;
let hasActiveSession = false;
for (const endEvent of chatEndEvents) {
const payload = endEvent.payload as ChatEventPayload | undefined;
if (payload?.durationMs) {
totalDurationMs += payload.durationMs;
}
if (payload?.sessionId) {
sessionIds.add(payload.sessionId);
}
if (payload?.agentName) {
lastAgentName = payload.agentName;
}
if (endEvent.createdAt > mostRecentActivity) {
mostRecentActivity = endEvent.createdAt;
}
}
// Verificar sessoes iniciadas mas nao encerradas (ativas)
const endedSessionIds = new Set(
chatEndEvents
.map((e) => (e.payload as ChatEventPayload | undefined)?.sessionId)
.filter(Boolean)
);
for (const startEvent of chatStartEvents) {
const payload = startEvent.payload as ChatEventPayload | undefined;
const sessionId = payload?.sessionId;
if (sessionId && !endedSessionIds.has(sessionId)) {
hasActiveSession = true;
sessionIds.add(sessionId);
}
if (payload?.agentName && !lastAgentName) {
lastAgentName = payload.agentName;
}
if (startEvent.createdAt > mostRecentActivity) {
mostRecentActivity = startEvent.createdAt;
}
}
// Se so tem sessao iniciada sem encerrar, mostrar o evento de inicio
if (chatEndEvents.length === 0 && chatStartEvents.length > 0) {
// Retornar o evento de inicio mais recente
const latestStart = chatStartEvents.reduce((a, b) =>
a.createdAt > b.createdAt ? a : b
);
return [...otherEvents, latestStart];
}
// Criar evento consolidado
const sessionCount = sessionIds.size || chatEndEvents.length;
const consolidatedEvent: TimelineEvent = {
_id: chatEndEvents[0]._id, // Usar ID do primeiro evento para manter referencia
type: "LIVE_CHAT_SUMMARY",
payload: {
sessionCount,
totalDurationMs,
agentName: lastAgentName || "Agente",
hasActiveSession,
},
createdAt: mostRecentActivity,
};
return [...otherEvents, consolidatedEvent];
}
type TemplateSummary = {
key: string;
label: string;
description: string;
defaultEnabled: boolean;
};
type TicketFieldScopeMap = Map<string, Doc<"ticketFields">[]>;
function plainTextLength(html: string): number {
try {
const text = String(html)
.replace(/<[^>]*>/g, "") // strip tags
.replace(/&nbsp;/g, " ")
.trim();
return text.length;
} catch {
return String(html ?? "").length;
}
}
const SLA_DEFAULT_ALERT_THRESHOLD = 0.8;
const BUSINESS_DAY_START_HOUR = 8;
const BUSINESS_DAY_END_HOUR = 18;
type SlaTimeMode = "business" | "calendar";
type TicketSlaSnapshot = {
categoryId?: Id<"ticketCategories">;
categoryName?: string;
priority: string;
responseTargetMinutes?: number;
responseMode: SlaTimeMode;
solutionTargetMinutes?: number;
solutionMode: SlaTimeMode;
alertThreshold: number;
pauseStatuses: TicketStatusNormalized[];
};
type SlaStatusValue = "pending" | "met" | "breached" | "n/a";
function normalizeSlaMode(input?: string | null): SlaTimeMode {
if (!input) return "calendar";
return input.toLowerCase() === "business" ? "business" : "calendar";
}
function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] {
if (!Array.isArray(statuses)) {
return ["PAUSED"];
}
const set = new Set<TicketStatusNormalized>();
for (const value of statuses) {
if (typeof value !== "string") continue;
const normalized = normalizeStatus(value);
set.add(normalized);
}
if (set.size === 0) {
set.add("PAUSED");
}
return Array.from(set);
}
async function resolveTicketSlaSnapshot(
ctx: AnyCtx,
tenantId: string,
category: Doc<"ticketCategories"> | null,
priority: string
): Promise<TicketSlaSnapshot | null> {
if (!category) {
return null;
}
const normalizedPriority = priority.trim().toUpperCase();
const rule =
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
)
.first()) ??
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first());
if (!rule) {
return null;
}
return {
categoryId: category._id,
categoryName: category.name,
priority: normalizedPriority,
responseTargetMinutes: rule.responseTargetMinutes ?? undefined,
responseMode: normalizeSlaMode(rule.responseMode),
solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined,
solutionMode: normalizeSlaMode(rule.solutionMode),
alertThreshold:
typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold)
? rule.alertThreshold
: SLA_DEFAULT_ALERT_THRESHOLD,
pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses),
};
}
function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) {
return {
responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode),
solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode),
};
}
function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null {
if (minutes === null || minutes === undefined || minutes <= 0) {
return null;
}
if (mode === "calendar") {
return startAt + minutes * 60000;
}
let remaining = minutes;
let cursor = alignToBusinessStart(new Date(startAt));
while (remaining > 0) {
if (!isBusinessDay(cursor)) {
cursor = advanceToNextBusinessStart(cursor);
continue;
}
const endOfDay = new Date(cursor);
endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0);
const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000;
if (minutesAvailable >= remaining) {
cursor = new Date(cursor.getTime() + remaining * 60000);
remaining = 0;
} else {
remaining -= minutesAvailable;
cursor = advanceToNextBusinessStart(endOfDay);
}
}
return cursor.getTime();
}
function alignToBusinessStart(date: Date): Date {
let result = new Date(date);
if (!isBusinessDay(result)) {
return advanceToNextBusinessStart(result);
}
if (result.getHours() >= BUSINESS_DAY_END_HOUR) {
return advanceToNextBusinessStart(result);
}
if (result.getHours() < BUSINESS_DAY_START_HOUR) {
result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
}
return result;
}
function advanceToNextBusinessStart(date: Date): Date {
const next = new Date(date);
next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
next.setDate(next.getDate() + 1);
while (!isBusinessDay(next)) {
next.setDate(next.getDate() + 1);
}
return next;
}
function isBusinessDay(date: Date) {
const day = date.getDay();
return day !== 0 && day !== 6;
}
function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) {
if (!snapshot) return {};
const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now);
return {
slaSnapshot: snapshot,
slaResponseDueAt: responseDueAt ?? undefined,
slaSolutionDueAt: solutionDueAt ?? undefined,
slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
dueAt: solutionDueAt ?? undefined,
};
}
function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) {
const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined;
if (!snapshot) return {};
const pauseSet = new Set(snapshot.pauseStatuses);
const currentlyPaused = typeof ticketDoc.slaPausedAt === "number";
if (pauseSet.has(nextStatus)) {
if (currentlyPaused) {
return {};
}
return {
slaPausedAt: now,
slaPausedBy: nextStatus,
};
}
if (currentlyPaused) {
const pauseStart = ticketDoc.slaPausedAt ?? now;
const delta = Math.max(0, now - pauseStart);
const patch: Record<string, unknown> = {
slaPausedAt: undefined,
slaPausedBy: undefined,
slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta,
};
if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") {
patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta;
}
if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") {
patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta;
patch.dueAt = ticketDoc.slaSolutionDueAt + delta;
}
return patch;
}
return {};
}
function getHourMinuteInTimezone(date: number, timeZone: string): { hour: number; minute: number } {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
const parts = formatter.formatToParts(new Date(date))
const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0")
const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0")
return { hour, minute }
}
function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
const merged = { ...ticketDoc } as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
delete merged[key];
} else {
merged[key] = value;
}
}
return merged as Doc<"tickets">;
}
async function pauseSessionForLunch(ctx: MutationCtx, ticket: Doc<"tickets">, session: Doc<"ticketWorkSessions">) {
const now = Date.now()
const durationMs = Math.max(0, now - session.startedAt)
const slaPatch = buildSlaStatusPatch(ticket, "PAUSED", now)
await ctx.db.patch(session._id, {
stoppedAt: now,
durationMs,
pauseReason: LUNCH_BREAK_REASON,
pauseNote: LUNCH_BREAK_NOTE,
})
await ctx.db.patch(ticket._id, {
working: false,
activeSessionId: undefined,
status: "PAUSED",
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + durationMs,
updatedAt: now,
...slaPatch,
})
await ctx.db.insert("ticketEvents", {
ticketId: ticket._id,
type: "WORK_PAUSED",
payload: {
actorId: null,
actorName: "Sistema",
actorAvatar: null,
sessionId: session._id,
sessionDurationMs: durationMs,
workType: (session.workType ?? "INTERNAL").toUpperCase(),
pauseReason: LUNCH_BREAK_REASON,
pauseReasonLabel: PAUSE_REASON_LABELS[LUNCH_BREAK_REASON],
pauseNote: LUNCH_BREAK_NOTE,
source: "lunch-break-cron",
},
createdAt: now,
})
}
function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
if (ticketDoc.firstResponseAt) {
return {};
}
if (!ticketDoc.slaResponseDueAt) {
return {
firstResponseAt: now,
slaResponseStatus: "n/a",
};
}
const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached";
return {
firstResponseAt: now,
slaResponseStatus: status,
};
}
function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") {
return {};
}
if (!ticketDoc.slaSolutionDueAt) {
return { slaSolutionStatus: "n/a" };
}
const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached";
return {
slaSolutionStatus: status,
};
}
function resolveFormTemplateLabel(
templateKey: string | null | undefined,
storedLabel: string | null | undefined
): string | null {
if (storedLabel && storedLabel.trim().length > 0) {
return storedLabel.trim();
}
const normalizedKey = templateKey?.trim();
if (!normalizedKey) {
return null;
}
const fallback = TICKET_FORM_CONFIG.find((entry) => entry.key === normalizedKey);
return fallback ? fallback.label : null;
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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<T extends { updatedAt: number }>(entries: T[], predicate: (entry: T) => boolean): T | null {
let latest: T | null = null;
for (const entry of entries) {
if (!predicate(entry)) continue;
if (!latest || entry.updatedAt > latest.updatedAt) {
latest = entry;
}
}
return latest;
}
function resolveFormEnabled(
template: string,
baseEnabled: boolean,
settings: Doc<"ticketFormSettings">[],
context: { companyId?: Id<"companies"> | null; userId: Id<"users"> }
): boolean {
const scoped = settings.filter((setting) => setting.template === template)
if (scoped.length === 0) {
return baseEnabled
}
const userSetting = findLatestSetting(scoped, (setting) => {
if (setting.scope !== "user") {
return false
}
if (!setting.userId) {
return false
}
return String(setting.userId) === String(context.userId)
})
if (userSetting) {
return userSetting.enabled ?? baseEnabled
}
const companyId = context.companyId ? String(context.companyId) : null
if (companyId) {
const companySetting = findLatestSetting(scoped, (setting) => {
if (setting.scope !== "company") {
return false
}
if (!setting.companyId) {
return false
}
return String(setting.companyId) === companyId
})
if (companySetting) {
return companySetting.enabled ?? baseEnabled
}
}
const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant")
if (tenantSetting) {
return tenantSetting.enabled ?? baseEnabled
}
return baseEnabled
}
async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<TemplateSummary[]> {
const templates = await ctx.db
.query("ticketFormTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (!templates.length) {
return TICKET_FORM_CONFIG.map((template) => ({
key: template.key,
label: template.label,
description: template.description,
defaultEnabled: template.defaultEnabled,
}));
}
return templates
.filter((tpl) => tpl.isArchived !== true)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
.map((tpl) => ({
key: tpl.key,
label: tpl.label,
description: tpl.description ?? "",
defaultEnabled: tpl.defaultEnabled ?? true,
}));
}
async function fetchTicketFieldsByScopes(
ctx: QueryCtx,
tenantId: string,
scopes: string[],
companyId: Id<"companies"> | null
): Promise<TicketFieldScopeMap> {
const scopeLookup = new Map<string, string>();
scopes.forEach((scope) => {
const trimmed = scope?.trim();
if (!trimmed) {
return;
}
const normalized = trimmed.toLowerCase();
if (!scopeLookup.has(normalized)) {
scopeLookup.set(normalized, trimmed);
}
});
if (!scopeLookup.size) {
return new Map();
}
const companyIdStr = companyId ? String(companyId) : null;
const result: TicketFieldScopeMap = new Map();
const allFields = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => {
const originalKey = scopeLookup.get(scopeKey);
if (!originalKey) {
return;
}
const current = result.get(originalKey);
if (current) {
current.push(field);
} else {
result.set(originalKey, [field]);
}
};
const normalizedScopeSet = new Set(scopeLookup.keys());
for (const field of allFields) {
const rawScope = field.scope?.trim();
const normalizedFieldScope = rawScope && rawScope.length > 0 ? rawScope.toLowerCase() : "all";
const fieldCompanyId = field.companyId ? String(field.companyId) : null;
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
continue;
}
if (normalizedFieldScope === "all") {
normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field));
continue;
}
if (normalizedFieldScope === "default") {
if (normalizedScopeSet.has("default")) {
addFieldToScope("default", field);
}
continue;
}
if (!normalizedScopeSet.has(normalizedFieldScope)) {
continue;
}
addFieldToScope(normalizedFieldScope, field);
}
return result;
}
async function fetchViewerScopedFormSettings(
ctx: QueryCtx,
tenantId: string,
templateKeys: string[],
viewerId: Id<"users">,
viewerCompanyId: Id<"companies"> | null
): Promise<Map<string, Doc<"ticketFormSettings">[]>> {
const uniqueTemplates = Array.from(new Set(templateKeys));
if (uniqueTemplates.length === 0) {
return new Map();
}
const keySet = new Set(uniqueTemplates);
const viewerIdStr = String(viewerId);
const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null;
const scopedMap = new Map<string, Doc<"ticketFormSettings">[]>();
const allSettings = await ctx.db
.query("ticketFormSettings")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
for (const setting of allSettings) {
if (!keySet.has(setting.template)) {
continue;
}
if (setting.scope === "company") {
if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) {
continue;
}
} else if (setting.scope === "user") {
if (!setting.userId || String(setting.userId) !== viewerIdStr) {
continue;
}
} else if (setting.scope !== "tenant") {
continue;
}
if (scopedMap.has(setting.template)) {
scopedMap.get(setting.template)!.push(setting);
} else {
scopedMap.set(setting.template, [setting]);
}
}
return scopedMap;
}
function normalizeDateOnlyValue(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
if (DATE_ONLY_REGEX.test(trimmed)) {
return trimmed;
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString().slice(0, 10);
}
const date =
value instanceof Date
? value
: typeof value === "number"
? new Date(value)
: null;
if (!date || Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString().slice(0, 10);
}
async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
await ensureTicketFormTemplatesForTenant(ctx, tenantId);
const now = Date.now();
for (const template of TICKET_FORM_CONFIG) {
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
if (!defaults.length) {
continue;
}
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
.collect();
if (template.key === "admissao") {
for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
const field = existing.find((f) => f.key === key);
if (!field) continue;
const updates: Partial<Doc<"ticketFields">> = {};
if (field.required) {
updates.required = false;
}
if (key === "colaborador_patrimonio") {
const desiredLabel = "Patrimônio do computador (se houver)";
if ((field.label ?? "").trim() !== desiredLabel) {
updates.label = desiredLabel;
}
}
if (Object.keys(updates).length) {
await ctx.db.patch(field._id, {
...updates,
updatedAt: now,
});
}
}
}
const existingKeys = new Set(existing.map((field) => field.key));
let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
for (const field of defaults) {
if (existingKeys.has(field.key)) {
// Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima
continue;
}
order += 1;
await ctx.db.insert("ticketFields", {
tenantId,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type,
required: Boolean(field.required),
options: field.options?.map((option) => ({
value: option.value,
label: option.label,
})),
scope: template.key,
companyId: field.companyId ?? undefined,
order,
createdAt: now,
updatedAt: now,
});
}
}
}
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) => `<p>${escapeHtml(line)}</p>`).join("")
: `<p>—</p>`;
return `<p><strong>Responsável atualizado:</strong> ${previous}${next}</p><p><strong>Motivo da troca:</strong></p>${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<TicketStatusNormalized, string> = {
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 `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" status="${normalizedStatus}" href="/tickets/${String(ticket._id)}" class="${TICKET_MENTION_ANCHOR_CLASSES}" rel="noopener noreferrer" target="_self" title="Chamado #${reference}${subject ? `${subject}` : ""}"><span class="${dotClass}"></span><span class="${TICKET_MENTION_REF_CLASSES}">#${reference}</span><span class="${TICKET_MENTION_SEP_CLASSES}">•</span><span class="${TICKET_MENTION_SUBJECT_CLASSES}">${truncated}</span></a>`
}
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<string> {
if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) {
return html
}
const mentionPattern = /<a\b[^>]*(?: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<string, string> = {}
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<AgentWorkTotals[]> {
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<Doc<"users"> | 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<TicketChatParticipant> {
const viewer = await requireUser(ctx, actorId, ticket.tenantId);
const normalizedRole = viewer.role ?? "";
if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") {
return { user: viewer.user, role: normalizedRole, kind: "staff" };
}
if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, viewer.user, ticket);
return { user: viewer.user, role: normalizedRole, kind: "manager" };
}
if (normalizedRole === "COLLABORATOR") {
// Verificar se e o solicitante
if (String(ticket.requesterId) === String(viewer.user._id)) {
return { user: viewer.user, role: normalizedRole, kind: "requester" };
}
// Verificar se esta vinculado a maquina do ticket
if (ticket.machineId) {
const machine = await ctx.db.get(ticket.machineId);
if (machine) {
const isLinkedToMachine =
machine.assignedUserId?.toString() === viewer.user._id.toString() ||
machine.linkedUserIds?.some((id) => id.toString() === viewer.user._id.toString());
if (isLinkedToMachine) {
return { user: viewer.user, role: normalizedRole, kind: "requester" };
}
}
}
throw new ConvexError("Apenas o solicitante pode conversar neste chamado");
}
throw new ConvexError("Usuário não possui acesso ao chat deste chamado");
}
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
chamados: "Chamados",
"Suporte N2": "Laboratório",
"suporte-n2": "Laboratório",
laboratorio: "Laboratório",
Laboratorio: "Laboratório",
visitas: "Visitas",
};
function renameQueueString(value?: string | null): string | null {
if (!value) return value ?? null;
const direct = QUEUE_RENAME_LOOKUP[value];
if (direct) return direct;
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
}
function normalizeQueueName(queue?: Doc<"queues"> | null): string | null {
if (!queue) return null;
const normalized = renameQueueString(queue.name);
if (normalized) {
return normalized;
}
if (queue.slug) {
const fromSlug = renameQueueString(queue.slug);
if (fromSlug) return fromSlug;
}
return queue.name;
}
function normalizeTeams(teams?: string[] | null): string[] {
if (!teams) return [];
return teams.map((team) => renameQueueString(team) ?? team);
}
type RequesterFallbackContext = {
ticketId?: Id<"tickets">;
fallbackName?: string | null;
fallbackEmail?: string | null;
};
function buildRequesterSummary(
requester: Doc<"users"> | null,
requesterId: Id<"users">,
context?: RequesterFallbackContext,
) {
if (requester) {
return {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: normalizeTeams(requester.teams),
};
}
const idString = String(requesterId);
const fallbackName =
typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0
? context.fallbackName.trim()
: "Solicitante não encontrado";
const fallbackEmailCandidate =
typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@")
? context.fallbackEmail
: null;
const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`;
if (process.env.NODE_ENV !== "test") {
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`;
if (!missingRequesterLogCache.has(cacheKey)) {
missingRequesterLogCache.add(cacheKey);
console.warn(
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
);
}
}
return {
id: requesterId,
name: fallbackName,
email: fallbackEmail,
teams: [],
};
}
type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] };
type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean };
function buildRequesterFromSnapshot(
requesterId: Id<"users">,
snapshot: UserSnapshot | null | undefined,
fallback?: RequesterFallbackContext
) {
if (snapshot) {
const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado")
const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`)
return {
id: requesterId,
name,
email,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot.teams ?? []),
}
}
return buildRequesterSummary(null, requesterId, fallback)
}
function buildAssigneeFromSnapshot(
assigneeId: Id<"users">,
snapshot: UserSnapshot | null | undefined
) {
const name = snapshot?.name?.trim?.() || "Usuário removido"
const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid`
return {
id: assigneeId,
name,
email,
avatarUrl: snapshot?.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot?.teams ?? []),
}
}
function buildCompanyFromSnapshot(
companyId: Id<"companies"> | undefined,
snapshot: CompanySnapshot | null | undefined
) {
if (!snapshot) return null
return {
id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">,
name: snapshot.name,
isAvulso: Boolean(snapshot.isAvulso ?? false),
}
}
type CommentAuthorFallbackContext = {
ticketId?: Id<"tickets">;
commentId?: Id<"ticketComments">;
};
type CommentAuthorSnapshot = {
name: string;
email?: string;
avatarUrl?: string;
teams?: string[];
};
export function buildCommentAuthorSummary(
comment: Doc<"ticketComments">,
author: Doc<"users"> | null,
context?: CommentAuthorFallbackContext,
) {
if (author) {
return {
id: author._id,
name: author.name,
email: author.email,
avatarUrl: author.avatarUrl,
teams: normalizeTeams(author.teams),
};
}
if (process.env.NODE_ENV !== "test") {
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : "";
const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"];
if (context?.commentId) cacheKeyParts.push(String(context.commentId));
const cacheKey = cacheKeyParts.join(":");
if (!missingCommentAuthorLogCache.has(cacheKey)) {
missingCommentAuthorLogCache.add(cacheKey);
console.warn(
`[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`,
);
}
}
const idString = String(comment.authorId);
const fallbackName = "Usuário removido";
const fallbackEmail = `author-${idString}@example.invalid`;
const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined;
if (snapshot) {
const name =
typeof snapshot.name === "string" && snapshot.name.trim().length > 0
? snapshot.name.trim()
: fallbackName;
const emailCandidate =
typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null;
const email = emailCandidate ?? fallbackEmail;
return {
id: comment.authorId,
name,
email,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: normalizeTeams(snapshot.teams ?? []),
};
}
return {
id: comment.authorId,
name: fallbackName,
email: fallbackEmail,
teams: [],
};
}
type CustomFieldInput = {
fieldId: Id<"ticketFields">;
value: unknown;
};
type NormalizedCustomField = {
fieldId: Id<"ticketFields">;
fieldKey: string;
label: string;
type: string;
value: unknown;
displayValue?: string;
};
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
switch (field.type) {
case "text":
return { value: String(raw).trim() };
case "number": {
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
if (!Number.isFinite(value)) {
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
}
return { value };
}
case "date": {
const normalized = normalizeDateOnlyValue(raw);
if (!normalized) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: normalized };
}
case "boolean": {
if (typeof raw === "boolean") {
return { value: raw };
}
if (typeof raw === "string") {
const normalized = raw.toLowerCase();
if (normalized === "true" || normalized === "1") return { value: true };
if (normalized === "false" || normalized === "0") return { value: false };
}
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
}
case "select": {
if (!field.options || field.options.length === 0) {
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
}
const value = String(raw);
const option = field.options.find((opt) => opt.value === value);
if (!option) {
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
}
return { value: option.value, displayValue: option.label ?? option.value };
}
default:
return { value: raw };
}
}
async function normalizeCustomFieldValues(
ctx: Pick<MutationCtx, "db">,
tenantId: string,
inputs: CustomFieldInput[] | undefined,
scope?: string | null
): Promise<NormalizedCustomField[]> {
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<Id<"ticketFields">, 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<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
let value: unknown = entry.value;
if (entry.type === "date") {
value = normalizeDateOnlyValue(entry.value) ?? entry.value;
}
acc[entry.fieldKey] = {
label: entry.label,
type: entry.type,
value,
displayValue: entry.displayValue,
};
return acc;
}, {});
}
type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined;
function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b);
}
function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string {
if (!entry) return "__undefined__";
return JSON.stringify({
value: normalizeEntryValue(entry.value),
displayValue: entry.displayValue ?? null,
});
}
function normalizeEntryValue(value: unknown): unknown {
if (value === undefined || value === null) return null;
if (value instanceof Date) return value.toISOString();
if (typeof value === "number" && Number.isNaN(value)) return "__nan__";
if (Array.isArray(value)) {
return value.map((item) => normalizeEntryValue(item));
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
Object.keys(record)
.sort()
.forEach((key) => {
normalized[key] = normalizeEntryValue(record[key]);
});
return normalized;
}
return value;
}
function getCustomFieldRecordEntry(
record: Record<string, { label: string; type: string; value: unknown; displayValue?: string }>,
key: string
): CustomFieldRecordEntry {
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
}
const DEFAULT_TICKETS_LIST_LIMIT = 120;
const MIN_TICKETS_LIST_LIMIT = 25;
const MAX_TICKETS_LIST_LIMIT = 400;
const MAX_FETCH_LIMIT = 400;
const BASE_FETCH_PADDING = 50;
const SEARCH_FETCH_PADDING = 200;
function clampTicketLimit(limit: number) {
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit)));
}
function computeFetchLimit(limit: number, hasSearch: boolean) {
const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING;
const target = limit + padding;
return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
}
async function loadDocs<TableName extends TableNames>(
ctx: QueryCtx,
ids: (Id<TableName> | null | undefined)[],
): Promise<Map<string, Doc<TableName>>> {
const uniqueIds = Array.from(
new Set(ids.filter((value): value is Id<TableName> => Boolean(value))),
);
if (uniqueIds.length === 0) {
return new Map();
}
const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id)));
const map = new Map<string, Doc<TableName>>();
docs.forEach((doc, index) => {
if (doc) {
map.set(String(uniqueIds[index]), doc);
}
});
return map;
}
function dedupeTicketsById(tickets: Doc<"tickets">[]) {
const seen = new Set<string>();
const result: Doc<"tickets">[] = [];
for (const ticket of tickets) {
const key = String(ticket._id);
if (seen.has(key)) continue;
seen.add(key);
result.push(ticket);
}
return result;
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
tenantId: v.string(),
status: v.optional(v.string()),
priority: v.optional(v.union(v.string(), v.array(v.string()))),
channel: v.optional(v.string()),
queueId: v.optional(v.id("queues")),
assigneeId: v.optional(v.id("users")),
requesterId: v.optional(v.id("users")),
search: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!args.viewerId) {
return [];
}
const viewerId = args.viewerId as Id<"users">;
const { user, role } = await requireUser(ctx, viewerId, args.tenantId);
if (role === "MANAGER" && !user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
const searchTerm = args.search?.trim().toLowerCase() ?? null;
const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT;
const requestedLimit = clampTicketLimit(requestedLimitRaw);
const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm));
let base: Doc<"tickets">[] = [];
if (role === "MANAGER") {
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.assigneeId) {
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.requesterId) {
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.queueId) {
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (normalizedStatusFilter) {
const baseQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
base = await baseQuery.order("desc").take(fetchLimit);
} else if (role === "COLLABORATOR") {
const viewerEmail = user.email.trim().toLowerCase();
const directQuery = ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId));
const directTickets = await directQuery.order("desc").take(fetchLimit);
let combined = directTickets;
if (directTickets.length < fetchLimit) {
const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
const fallbackMatches = fallbackRaw.filter((ticket) => {
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
if (typeof snapshotEmail !== "string") return false;
return snapshotEmail.trim().toLowerCase() === viewerEmail;
});
combined = dedupeTicketsById([...directTickets, ...fallbackMatches]);
}
base = combined.slice(0, fetchLimit);
} else {
const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
base = await baseQuery.order("desc").take(fetchLimit);
}
let filtered = base;
if (role === "MANAGER") {
filtered = filtered.filter((t) => t.companyId === user.companyId);
}
if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority));
if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
if (normalizedStatusFilter) {
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
}
if (searchTerm) {
filtered = filtered.filter(
(t) =>
t.subject.toLowerCase().includes(searchTerm) ||
t.summary?.toLowerCase().includes(searchTerm) ||
`#${t.reference}`.toLowerCase().includes(searchTerm)
);
}
const limited = filtered.slice(0, requestedLimit);
if (limited.length === 0) {
return [];
}
const [
requesterDocs,
assigneeDocs,
queueDocs,
companyDocs,
machineDocs,
activeSessionDocs,
categoryDocs,
subcategoryDocs,
] = await Promise.all([
loadDocs(ctx, limited.map((t) => t.requesterId)),
loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)),
loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)),
]);
const serverNow = Date.now();
const result = limited.map((t) => {
const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined;
const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null;
const requesterSummary = requesterDoc
? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id })
: buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id });
const assigneeDoc = t.assigneeId
? assigneeDocs.get(String(t.assigneeId)) ?? null
: null;
const assigneeSummary = t.assigneeId
? assigneeDoc
? {
id: assigneeDoc._id,
name: assigneeDoc.name,
email: assigneeDoc.email,
avatarUrl: assigneeDoc.avatarUrl,
teams: normalizeTeams(assigneeDoc.teams),
}
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
: null;
const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null;
const queueName = normalizeQueueName(queueDoc);
const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null;
const companySummary = companyDoc
? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false }
: t.companyId || t.companySnapshot
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
: null;
const machineSnapshot = t.machineSnapshot as
| {
hostname?: string;
persona?: string;
assignedUserName?: string;
assignedUserEmail?: string;
status?: string;
}
| undefined;
const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null;
let machineSummary:
| {
id: Id<"machines"> | null;
hostname: string | null;
persona: string | null;
assignedUserName: string | null;
assignedUserEmail: string | null;
status: string | null;
}
| null = null;
if (t.machineId) {
machineSummary = {
id: t.machineId,
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
};
} else if (machineSnapshot) {
machineSummary = {
id: null,
hostname: machineSnapshot.hostname ?? null,
persona: machineSnapshot.persona ?? null,
assignedUserName: machineSnapshot.assignedUserName ?? null,
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
status: machineSnapshot.status ?? null,
};
}
const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null;
const categorySummary = categoryDoc
? { id: categoryDoc._id, name: categoryDoc.name }
: null;
const subcategoryDoc = t.subcategoryId
? subcategoryDocs.get(String(t.subcategoryId)) ?? null
: null;
const subcategorySummary = subcategoryDoc
? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId }
: null;
const activeSessionDoc = t.activeSessionId
? activeSessionDocs.get(String(t.activeSessionId)) ?? null
: null;
const activeSession = activeSessionDoc
? {
id: activeSessionDoc._id,
agentId: activeSessionDoc.agentId,
startedAt: activeSessionDoc.startedAt,
workType: activeSessionDoc.workType ?? "INTERNAL",
}
: null;
return {
id: t._id,
reference: t.reference,
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
csatComment:
typeof t.csatComment === "string" && t.csatComment.trim().length > 0
? t.csatComment.trim()
: null,
csatRatedAt: t.csatRatedAt ?? null,
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
formTemplate: t.formTemplate ?? null,
formTemplateLabel: resolveFormTemplateLabel(
t.formTemplate ?? null,
t.formTemplateLabel ?? null,
),
company: companySummary,
requester: requesterSummary,
assignee: assigneeSummary,
slaPolicy: null,
dueAt: t.dueAt ?? null,
visitStatus: t.visitStatus ?? null,
visitPerformedAt: t.visitPerformedAt ?? null,
firstResponseAt: t.firstResponseAt ?? null,
resolvedAt: t.resolvedAt ?? null,
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
category: categorySummary,
subcategory: subcategorySummary,
machine: machineSummary,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: t.internalWorkedMs ?? 0,
externalWorkedMs: t.externalWorkedMs ?? 0,
serverNow,
activeSession,
},
};
});
// sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
},
});
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { tenantId, id, viewerId }) => {
const { user, role } = await requireUser(ctx, viewerId, tenantId)
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
if (role === "COLLABORATOR") {
const isOwnerById = String(t.requesterId) === String(viewerId)
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
const viewerEmail = user.email.trim().toLowerCase()
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
if (!isOwnerById && !isOwnerByEmail) {
return null
}
}
// no customer role; managers are constrained to company via ensureManagerTicketAccess
let requester: Doc<"users"> | null = null
if (role === "MANAGER") {
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
}
if (!requester) {
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
}
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const machineSnapshot = t.machineSnapshot as
| {
hostname?: string
persona?: string
assignedUserName?: string
assignedUserEmail?: string
status?: string
}
| undefined;
let machineSummary:
| {
id: Id<"machines"> | null
hostname: string | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
status: string | null
}
| null = null;
if (t.machineId) {
const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null;
machineSummary = {
id: t.machineId,
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
};
} else if (machineSnapshot) {
machineSummary = {
id: null,
hostname: machineSnapshot.hostname ?? null,
persona: machineSnapshot.persona ?? null,
assignedUserName: machineSnapshot.assignedUserName ?? null,
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
status: machineSnapshot.status ?? null,
};
}
const queueName = normalizeQueueName(queue);
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.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<string, unknown>
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,
formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null),
chatEnabled: Boolean(t.chatEnabled),
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
reopenDeadline: t.reopenDeadline ?? null,
reopenedAt: t.reopenedAt ?? null,
visitStatus: t.visitStatus ?? null,
visitPerformedAt: t.visitPerformedAt ?? null,
description: undefined,
customFields: customFieldsRecord,
timeline: consolidateChatEventsInTimeline(timelineRecords).map((ev) => {
let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
payload = { ...payload, queueName: normalized };
}
}
return {
id: ev._id,
type: ev.type,
payload,
createdAt: ev.createdAt,
};
}),
comments: commentsHydrated,
};
},
});
export const create = mutation({
args: {
actorId: v.id("users"),
tenantId: v.string(),
subject: v.string(),
summary: v.optional(v.string()),
priority: v.string(),
channel: v.string(),
queueId: v.optional(v.id("queues")),
requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
machineId: v.optional(v.id("machines")),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
formTemplate: v.optional(v.string()),
chatEnabled: v.optional(v.boolean()),
visitDate: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
// no customer role; managers validated below
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
throw new ConvexError("Somente a equipe interna pode definir o responsável")
}
let initialAssigneeId: Id<"users"> | undefined
let initialAssignee: Doc<"users"> | null = null
if (args.assigneeId) {
const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== args.tenantId) {
throw new ConvexError("Responsável inválido")
}
const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase()
if (!STAFF_ROLES.has(normalizedAssigneeRole)) {
throw new ConvexError("Responsável inválido")
}
initialAssigneeId = assignee._id
initialAssignee = assignee
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
initialAssigneeId = actorUser._id
initialAssignee = actorUser
}
const subject = args.subject.trim();
if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
}
if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
}
const category = await ctx.db.get(args.categoryId);
if (!category || category.tenantId !== args.tenantId) {
throw new ConvexError("Categoria inválida");
}
const subcategory = await ctx.db.get(args.subcategoryId);
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
throw new ConvexError("Subcategoria inválida");
}
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
if (!requester || requester.tenantId !== args.tenantId) {
throw new ConvexError("Solicitante inválido")
}
if (role === "MANAGER") {
if (!actorUser.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (requester.companyId !== actorUser.companyId) {
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
}
}
let machineDoc: Doc<"machines"> | null = null
if (args.machineId) {
const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Dispositivo inválida para este chamado")
}
machineDoc = machine
}
let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
let formTemplateLabel: string | null = null;
if (formTemplateKey) {
const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey);
if (templateDoc && templateDoc.isArchived !== true) {
formTemplateLabel = templateDoc.label;
} else {
const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey);
if (fallbackTemplate) {
formTemplateLabel = fallbackTemplate.label;
} else {
formTemplateKey = null;
}
}
}
const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
const normalizedCustomFields = await normalizeCustomFieldValues(
ctx,
args.tenantId,
args.customFields ?? undefined,
formTemplateKey,
);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
.order("desc")
.take(1);
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = "PENDING";
const requesterSnapshot = {
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
if (!companyDoc && machineDoc?.companyId) {
const candidateCompany = await ctx.db.get(machineDoc.companyId)
if (candidateCompany && candidateCompany.tenantId === args.tenantId) {
companyDoc = candidateCompany as Doc<"companies">
}
}
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const 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 slaFields = applySlaSnapshot(slaSnapshot, now)
let resolvedQueueDoc: Doc<"queues"> | null = null
if (resolvedQueueId) {
const queueDoc = await ctx.db.get(resolvedQueueId)
if (queueDoc && queueDoc.tenantId === args.tenantId) {
resolvedQueueDoc = queueDoc as Doc<"queues">
}
}
const queueLabel = (resolvedQueueDoc?.slug ?? resolvedQueueDoc?.name ?? "").toLowerCase()
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
const visitDueAt =
typeof args.visitDate === "number" && Number.isFinite(args.visitDate) ? args.visitDate : null
if (isVisitQueue && !visitDueAt) {
throw new ConvexError("Informe a data da visita para tickets da fila de visitas")
}
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject,
summary: args.summary?.trim() || undefined,
status: initialStatus,
priority: args.priority,
channel: args.channel,
queueId: resolvedQueueId,
categoryId: args.categoryId,
subcategoryId: args.subcategoryId,
requesterId: args.requesterId,
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: 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,
formTemplateLabel: formTemplateLabel ?? undefined,
chatEnabled,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
createdAt: now,
updatedAt: now,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
tags: [],
slaPolicyId: undefined,
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
visitStatus: isVisitQueue ? "scheduled" : undefined,
visitPerformedAt: undefined,
...slaFields,
});
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
createdAt: now,
});
if (initialAssigneeId && initialAssignee) {
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "ASSIGNEE_CHANGED",
payload: {
assigneeId: initialAssigneeId,
assigneeName: initialAssignee.name,
actorId: args.actorId,
actorName: actorUser.name,
actorAvatar: actorUser.avatarUrl ?? undefined,
previousAssigneeId: null,
previousAssigneeName: "Não atribuído",
},
createdAt: now,
})
}
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,
});
const isStaffResponder =
requestedVisibility === "PUBLIC" &&
!isRequester &&
(normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER");
const responsePatch =
isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {};
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
// Notificação por e-mail: comentário público para o solicitante
try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail,
ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
})
}
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
}
return id;
},
});
export const updateComment = mutation({
args: {
ticketId: v.id("tickets"),
commentId: v.id("ticketComments"),
actorId: v.id("users"),
body: v.string(),
},
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) {
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para editar")
}
} else {
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId)
const bodyPlainLen = plainTextLength(normalizedBody)
if (bodyPlainLen > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const now = Date.now();
await ctx.db.patch(commentId, {
body: normalizedBody,
updatedAt: now,
});
await ctx.db.insert("ticketEvents", {
ticketId,
type: "COMMENT_EDITED",
payload: {
commentId,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
});
await ctx.db.patch(ticketId, { updatedAt: now });
},
});
export const removeCommentAttachment = mutation({
args: {
ticketId: v.id("tickets"),
commentId: v.id("ticketComments"),
attachmentId: v.id("_storage"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não pode alterar anexos de outro usuário")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) {
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para alterar anexos")
}
} else {
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const attachments = comment.attachments ?? [];
const target = attachments.find((att) => att.storageId === attachmentId);
if (!target) {
throw new ConvexError("Anexo não encontrado");
}
await ctx.storage.delete(attachmentId);
const now = Date.now();
await ctx.db.patch(commentId, {
attachments: attachments.filter((att) => att.storageId !== attachmentId),
updatedAt: now,
});
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ATTACHMENT_REMOVED",
payload: {
attachmentId,
attachmentName: target.name,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
});
await ctx.db.patch(ticketId, { updatedAt: now });
},
});
export const updateStatus = mutation({
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, status, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedStatus = normalizeStatus(status)
if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) {
throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
}
const now = Date.now();
const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
createdAt: now,
});
},
});
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<string>()
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<string>(
linkedTickets.map((rel) => String(rel._id)),
),
).map((id) => id as Id<"tickets">)
const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch);
const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now);
await ctx.db.patch(ticketId, {
status: normalizedStatus,
resolvedAt: now,
closedAt: now,
updatedAt: now,
reopenDeadline,
reopenedAt: undefined,
resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined,
relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
activeSessionId: undefined,
working: false,
...slaPausePatch,
...slaSolutionPatch,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
createdAt: now,
})
// Notificação por e-mail: encerramento do chamado
try {
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
if (email) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email,
ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
})
}
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
}
for (const rel of linkedTickets) {
const existing = new Set<string>((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")
}
const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now)
await ctx.db.patch(ticketId, {
status: "AWAITING_ATTENDANCE",
reopenedAt: now,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: now,
...slaPatch,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "TICKET_REOPENED",
payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole },
createdAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId },
createdAt: now,
})
return { ok: true, reopenedAt: now }
}
export const reopenTicket = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: reopenTicketHandler,
})
export const changeAssignee = mutation({
args: {
ticketId: v.id("tickets"),
assigneeId: v.id("users"),
actorId: v.id("users"),
reason: v.optional(v.string()),
},
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const viewerUser = viewer.user
const isAdmin = viewer.role === "ADMIN"
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Responsável inválido")
}
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
const hasActiveSession = Boolean(ticketDoc.activeSessionId)
const currentAssigneeId = ticketDoc.assigneeId ?? null
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
}
const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim()
if (normalizedReason.length > 0 && normalizedReason.length < 5) {
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco")
}
if (normalizedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
}
const previousAssigneeName =
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
"Não atribuído"
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
const now = Date.now();
const assigneeSnapshot = {
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl ?? undefined,
teams: assignee.teams ?? undefined,
}
const ticketPatch: Partial<Doc<"tickets">> = {
assigneeId,
assigneeSnapshot,
updatedAt: now,
}
if (hasActiveSession) {
const session = await ctx.db.get(ticketDoc.activeSessionId as Id<"ticketWorkSessions">)
if (session) {
const durationMs = Math.max(0, now - session.startedAt)
const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
await ctx.db.patch(session._id, {
stoppedAt: now,
durationMs,
})
ticketPatch.totalWorkedMs = (ticketDoc.totalWorkedMs ?? 0) + durationMs
ticketPatch.internalWorkedMs = (ticketDoc.internalWorkedMs ?? 0) + deltaInternal
ticketPatch.externalWorkedMs = (ticketDoc.externalWorkedMs ?? 0) + deltaExternal
const newSessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: assigneeId,
workType: sessionType,
startedAt: now,
})
ticketPatch.activeSessionId = newSessionId
ticketPatch.working = true
ticketPatch.status = "AWAITING_ATTENDANCE"
ticketDoc.totalWorkedMs = ticketPatch.totalWorkedMs as number
ticketDoc.internalWorkedMs = ticketPatch.internalWorkedMs as number
ticketDoc.externalWorkedMs = ticketPatch.externalWorkedMs as number
ticketDoc.activeSessionId = newSessionId
} else {
ticketPatch.activeSessionId = undefined
ticketPatch.working = false
}
}
await ctx.db.patch(ticketId, ticketPatch);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: {
assigneeId,
assigneeName: assignee.name,
actorId,
actorName: viewerUser.name,
actorAvatar: viewerUser.avatarUrl ?? undefined,
previousAssigneeId: currentAssigneeId,
previousAssigneeName,
reason: normalizedReason.length > 0 ? normalizedReason : undefined,
},
createdAt: now,
});
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()
// Verificar maquina e sessao de chat ao vivo
let liveChat: {
hasMachine: boolean
machineOnline: boolean
machineHostname: string | null
activeSession: {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
unreadByAgent: number
} | null
} = {
hasMachine: false,
machineOnline: false,
machineHostname: null,
activeSession: null,
}
if (ticketDoc.machineId) {
const machine = await ctx.db.get(ticketDoc.machineId)
if (machine) {
const fiveMinutesAgo = now - 5 * 60 * 1000
liveChat.hasMachine = true
liveChat.machineOnline = Boolean(machine.lastHeartbeatAt && machine.lastHeartbeatAt > fiveMinutesAgo)
liveChat.machineHostname = machine.hostname
// Verificar sessao ativa
const activeSession = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (activeSession) {
liveChat.activeSession = {
sessionId: activeSession._id,
agentId: activeSession.agentId,
agentName: activeSession.agentSnapshot?.name ?? null,
startedAt: activeSession.startedAt,
unreadByAgent: activeSession.unreadByAgent ?? 0,
}
}
}
}
return {
ticketId: String(ticketId),
chatEnabled,
status,
canPost,
reopenDeadline: ticketDoc.reopenDeadline ?? null,
liveChat,
messages: messages
.sort((a, b) => a.createdAt - b.createdAt)
.map((message) => ({
id: message._id,
body: message.body,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
authorId: String(message.authorId),
authorName: message.authorSnapshot?.name ?? null,
authorEmail: message.authorSnapshot?.email ?? null,
attachments: (message.attachments ?? []).map((attachment) => ({
storageId: attachment.storageId,
name: attachment.name,
size: attachment.size ?? null,
type: attachment.type ?? null,
})),
readBy: (message.readBy ?? []).map((entry) => ({
userId: String(entry.userId),
readAt: entry.readAt,
})),
})),
}
},
})
export const listTicketForms = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
const viewer = await requireUser(ctx, viewerId, tenantId)
const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
const viewerRole = (viewer.role ?? "").toUpperCase()
const templates = await fetchTemplateSummaries(ctx, tenantId)
const defaultTemplate: TemplateSummary = {
key: "default",
label: "Chamado",
description: "Campos adicionais exibidos em chamados gerais ou sem template específico.",
defaultEnabled: true,
}
const templatesWithDefault = [defaultTemplate, ...templates.filter((template) => template.key !== "default")]
const scopes = templatesWithDefault.map((template) => template.key)
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
const settingsByTemplate = staffOverride
? new Map<string, Doc<"ticketFormSettings">[]>()
: await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId)
const forms = [] as Array<{
key: string
label: string
description: string
fields: Array<{
id: Id<"ticketFields">
key: string
label: string
type: string
required: boolean
description: string
options: { value: string; label: string }[]
}>
}>
for (const template of templatesWithDefault) {
const templateSettings = settingsByTemplate.get(template.key) ?? []
let enabled = staffOverride
? true
: resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, {
companyId: viewerCompanyId,
userId: viewer.user._id,
})
if (!enabled) {
continue
}
const scopedFields = fieldsByScope.get(template.key) ?? []
forms.push({
key: template.key,
label: template.label,
description: template.description,
fields: scopedFields
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
type: field.type,
required: Boolean(field.required),
description: field.description ?? "",
options: field.options ?? [],
})),
})
}
return forms
},
})
export const findByReference = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
reference: v.number(),
},
handler: async (ctx, { tenantId, viewerId, reference }) => {
const viewer = await requireUser(ctx, viewerId, tenantId)
const ticket = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference))
.first()
if (!ticket) {
return null
}
const normalizedRole = viewer.role ?? ""
if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">)
} else if (normalizedRole === "COLLABORATOR") {
if (String(ticket.requesterId) !== String(viewer.user._id)) {
return null
}
} else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
return null
}
return {
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: ticket.status,
}
},
})
export const postChatMessage = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
},
handler: async (ctx, { ticketId, actorId, body, attachments }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
if (!ticketDoc.chatEnabled) {
throw new ConvexError("Chat não habilitado para este chamado")
}
const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc)
const now = Date.now()
if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") {
throw new ConvexError("O chat deste chamado está encerrado")
}
const trimmedBody = body.replace(/\r\n/g, "\n").trim()
const files = attachments ?? []
// Validar que há pelo menos texto ou anexo
if (trimmedBody.length === 0 && files.length === 0) {
throw new ConvexError("Digite uma mensagem ou anexe um arquivo")
}
if (trimmedBody.length > 4000) {
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
}
// Validar anexos
if (files.length > 5) {
throw new ConvexError("Envie até 5 arquivos por mensagem")
}
const maxAttachmentSize = 5 * 1024 * 1024
for (const file of files) {
if (typeof file.size === "number" && file.size > maxAttachmentSize) {
throw new ConvexError("Cada arquivo pode ter até 5MB")
}
}
// Normalizar corpo apenas se houver texto
let normalizedBody = ""
if (trimmedBody.length > 0) {
normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId)
const plainLength = plainTextLength(normalizedBody)
if (plainLength > 4000) {
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
}
}
const authorSnapshot: CommentAuthorSnapshot = {
name: participant.user.name,
email: participant.user.email,
avatarUrl: participant.user.avatarUrl ?? undefined,
teams: participant.user.teams ?? undefined,
}
const messageId = await ctx.db.insert("ticketChatMessages", {
ticketId,
tenantId: ticketDoc.tenantId,
companyId: ticketDoc.companyId ?? undefined,
authorId: actorId,
authorSnapshot,
body: normalizedBody,
attachments: files,
notifiedAt: undefined,
createdAt: now,
updatedAt: now,
readBy: [{ userId: actorId, readAt: now }],
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "CHAT_MESSAGE_ADDED",
payload: {
messageId,
authorId: actorId,
authorName: participant.user.name,
actorRole: participant.role ?? null,
},
createdAt: now,
})
await ctx.db.patch(ticketId, { updatedAt: now })
// Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
const actorRole = participant.role?.toUpperCase() ?? ""
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
const activeSession = await ctx.db
.query("liveChatSessions")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.filter((q) => q.eq(q.field("status"), "ACTIVE"))
.first()
if (activeSession) {
await ctx.db.patch(activeSession._id, {
unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1,
lastActivityAt: 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<string, { userId: Id<"users">; 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 const ensureTicketFormDefaults = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { tenantId, actorId }) => {
await requireUser(ctx, actorId, tenantId);
await ensureTicketFormDefaultsForTenant(ctx, tenantId);
return { ok: true };
},
});
export async function submitCsatHandler(
ctx: MutationCtx,
{ ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }
) {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const normalizedStatus = normalizeStatus(ticket.status)
if (normalizedStatus !== "RESOLVED") {
throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado")
}
const viewer = await requireUser(ctx, actorId, ticket.tenantId)
const normalizedRole = (viewer.role ?? "").toUpperCase()
if (normalizedRole !== "COLLABORATOR") {
throw new ConvexError("Somente o solicitante pode avaliar o chamado")
}
const viewerEmail = viewer.user.email.trim().toLowerCase()
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null
const isOwnerById = String(ticket.requesterId) === String(viewer.user._id)
const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false
if (!isOwnerById && !isOwnerByEmail) {
throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado")
}
if (typeof ticket.csatScore === "number") {
throw new ConvexError("Este chamado já possui uma avaliação registrada")
}
if (!Number.isFinite(score)) {
throw new ConvexError("Pontuação inválida")
}
const resolvedMaxScore =
Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5
const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score)))
const normalizedComment =
typeof comment === "string"
? comment
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trim())
.join("\n")
.trim()
: ""
if (normalizedComment.length > 2000) {
throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)")
}
const now = Date.now()
let csatAssigneeId: Id<"users"> | undefined
let csatAssigneeSnapshot:
| {
name: string
email?: string
avatarUrl?: string
teams?: string[]
}
| undefined
if (ticket.assigneeId) {
const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null
if (assigneeDoc) {
csatAssigneeId = assigneeDoc._id
csatAssigneeSnapshot = {
name: assigneeDoc.name,
email: assigneeDoc.email,
avatarUrl: assigneeDoc.avatarUrl ?? undefined,
teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined,
}
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
const snapshot = ticket.assigneeSnapshot as {
name?: string
email?: string
avatarUrl?: string
teams?: string[]
}
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
csatAssigneeId = ticket.assigneeId
csatAssigneeSnapshot = {
name: snapshot.name,
email: snapshot.email ?? undefined,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: snapshot.teams ?? undefined,
}
}
}
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
const snapshot = ticket.assigneeSnapshot as {
name?: string
email?: string
avatarUrl?: string
teams?: string[]
}
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
csatAssigneeSnapshot = {
name: snapshot.name,
email: snapshot.email ?? undefined,
avatarUrl: snapshot.avatarUrl ?? undefined,
teams: snapshot.teams ?? undefined,
}
}
}
await ctx.db.patch(ticketId, {
csatScore: normalizedScore,
csatMaxScore: resolvedMaxScore,
csatComment: normalizedComment.length > 0 ? normalizedComment : undefined,
csatRatedAt: now,
csatRatedBy: actorId,
csatAssigneeId,
csatAssigneeSnapshot,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "CSAT_RATED",
payload: {
score: normalizedScore,
maxScore: resolvedMaxScore,
comment: normalizedComment.length > 0 ? normalizedComment : undefined,
ratedBy: actorId,
assigneeId: csatAssigneeId ?? null,
assigneeName: csatAssigneeSnapshot?.name ?? null,
},
createdAt: now,
})
return {
ok: true,
score: normalizedScore,
maxScore: resolvedMaxScore,
comment: normalizedComment.length > 0 ? normalizedComment : null,
ratedAt: now,
}
}
export const submitCsat = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
score: v.number(),
maxScore: v.optional(v.number()),
comment: v.optional(v.string()),
},
handler: submitCsatHandler,
})
export const changeRequester = mutation({
args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, requesterId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const viewerRole = (viewer.role ?? "AGENT").toUpperCase()
const actor = viewer.user
if (String(ticketDoc.requesterId) === String(requesterId)) {
return { status: "unchanged" }
}
const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null
if (!requester || requester.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Solicitante inválido")
}
if (viewerRole === "MANAGER") {
if (!actor.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (requester.companyId !== actor.companyId) {
throw new ConvexError("Gestores só podem alterar para usuários da própria empresa")
}
}
const now = Date.now()
const requesterSnapshot = {
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
let companyId: Id<"companies"> | undefined
let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined
if (requester.companyId) {
const company = await ctx.db.get(requester.companyId)
if (company) {
companyId = company._id as Id<"companies">
companySnapshot = {
name: company.name,
slug: company.slug ?? undefined,
isAvulso: company.isAvulso ?? undefined,
}
}
}
const patch: Record<string, unknown> = {
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 updateVisitSchedule = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
visitDate: v.number(),
},
handler: async (ctx, { ticketId, actorId, visitDate }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a data da visita")
}
if (!Number.isFinite(visitDate)) {
throw new ConvexError("Data da visita inválida")
}
if (!ticketDoc.queueId) {
throw new ConvexError("Este ticket não possui fila configurada")
}
const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
if (!queue) {
throw new ConvexError("Fila não encontrada para este ticket")
}
const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
if (!isVisitQueue) {
throw new ConvexError("Somente tickets da fila de visitas possuem data de visita")
}
const now = Date.now()
const previousVisitDate = typeof ticketDoc.dueAt === "number" ? ticketDoc.dueAt : null
const actor = viewer.user
await ctx.db.patch(ticketId, {
dueAt: visitDate,
visitStatus: "scheduled",
visitPerformedAt: undefined,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "VISIT_SCHEDULE_CHANGED",
payload: {
visitDate,
previousVisitDate,
actorId,
actorName: actor.name,
actorAvatar: actor.avatarUrl ?? undefined,
},
createdAt: now,
})
return { status: "updated" }
},
})
export const updateVisitStatus = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
status: v.string(),
performedAt: v.optional(v.number()),
},
handler: async (ctx, { ticketId, actorId, status, performedAt }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar o status da visita")
}
const normalizedStatus = (status ?? "").toLowerCase()
if (!VISIT_STATUSES.has(normalizedStatus)) {
throw new ConvexError("Status da visita inválido")
}
if (!ticketDoc.queueId) {
throw new ConvexError("Este ticket não possui fila configurada")
}
const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
if (!queue) {
throw new ConvexError("Fila não encontrada para este ticket")
}
const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
if (!isVisitQueue) {
throw new ConvexError("Somente tickets da fila de visitas possuem status de visita")
}
const now = Date.now()
const completed = VISIT_COMPLETED_STATUSES.has(normalizedStatus)
const resolvedPerformedAt =
completed && typeof performedAt === "number" && Number.isFinite(performedAt) ? performedAt : completed ? now : undefined
await ctx.db.patch(ticketId, {
visitStatus: normalizedStatus,
visitPerformedAt: completed ? resolvedPerformedAt : undefined,
// Mantemos dueAt para não perder o agendamento original.
dueAt: ticketDoc.dueAt,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "VISIT_STATUS_CHANGED",
payload: {
status: normalizedStatus,
performedAt: resolvedPerformedAt,
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl ?? undefined,
},
createdAt: now,
})
return { status: normalizedStatus }
},
})
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a fila do chamado")
}
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now()
const queueName = normalizeQueueName(queue)
const normalizedQueueLabel = (queueName ?? queue.name ?? "").toLowerCase()
const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword))
const patch: Partial<Doc<"tickets">> = { queueId, updatedAt: now }
if (!isVisitQueueTarget) {
patch.dueAt = ticketDoc.slaSolutionDueAt ?? undefined
}
await ctx.db.patch(ticketId, patch)
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
payload: { queueId, queueName, actorId },
createdAt: now,
})
},
});
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<string, string> = { 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 currentAssigneeId = ticketDoc.assigneeId ?? null
const now = Date.now()
if (!currentAssigneeId) {
throw new ConvexError("Defina um responsável antes de iniciar o atendimento")
}
if (ticketDoc.activeSessionId) {
const session = await ctx.db.get(ticketDoc.activeSessionId)
return {
status: "already_started",
sessionId: ticketDoc.activeSessionId,
startedAt: session?.startedAt ?? now,
serverNow: now,
}
}
const sessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: currentAssigneeId,
workType: (workType ?? "INTERNAL").toUpperCase(),
startedAt: now,
})
const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now);
await ctx.db.patch(ticketId, {
working: true,
activeSessionId: sessionId,
status: "AWAITING_ATTENDANCE",
updatedAt: now,
...slaStartPatch,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl,
sessionId,
workType: (workType ?? "INTERNAL").toUpperCase(),
},
createdAt: now,
})
return { status: "started", sessionId, startedAt: now, serverNow: now }
},
})
export const pauseWork = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
reason: v.string(),
note: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, reason, note }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const isAdmin = viewer.role === "ADMIN"
if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode pausar este chamado")
}
if (!ticketDoc.activeSessionId) {
const normalizedStatus = normalizeStatus(ticketDoc.status)
if (normalizedStatus === "AWAITING_ATTENDANCE") {
const now = Date.now()
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
await ctx.db.patch(ticketId, {
status: "PAUSED",
working: false,
updatedAt: now,
...slaPausePatch,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: {
to: "PAUSED",
toLabel: STATUS_LABELS.PAUSED,
actorId,
},
createdAt: now,
})
return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now }
}
return { status: "already_paused" }
}
if (!PAUSE_REASON_LABELS[reason]) {
throw new ConvexError("Motivo de pausa inválido")
}
const session = await ctx.db.get(ticketDoc.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
return { status: "session_missing" }
}
const now = Date.now()
const durationMs = now - session.startedAt
await ctx.db.patch(ticketDoc.activeSessionId, {
stoppedAt: now,
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
})
const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
await ctx.db.patch(ticketId, {
working: false,
activeSessionId: undefined,
status: "PAUSED",
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now,
...slaPausePatch,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_PAUSED",
payload: {
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
workType: sessionType,
pauseReason: reason,
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
pauseNote: note ?? "",
},
createdAt: now,
})
return {
status: "paused",
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
serverNow: now,
}
},
})
export const pauseInternalSessionsForLunch = mutation({
args: {},
handler: async (ctx) => {
const now = Date.now()
const { hour } = getHourMinuteInTimezone(now, LUNCH_BREAK_TIMEZONE)
if (hour !== LUNCH_BREAK_HOUR) {
return { skipped: true, reason: "outside_lunch_window" as const }
}
const activeSessions = await ctx.db
.query("ticketWorkSessions")
.filter((q) => q.eq(q.field("stoppedAt"), undefined))
.collect()
let paused = 0
for (const sessionDoc of activeSessions) {
const session = sessionDoc as Doc<"ticketWorkSessions">
const workType = (session.workType ?? "INTERNAL").toUpperCase()
if (workType !== "INTERNAL") continue
const ticket = (await ctx.db.get(session.ticketId)) as Doc<"tickets"> | null
if (!ticket || ticket.activeSessionId !== session._id) {
continue
}
await pauseSessionForLunch(ctx, ticket, session)
paused += 1
}
return { skipped: false, paused }
},
})
export const adjustWorkSummary = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
internalWorkedMs: v.number(),
externalWorkedMs: v.number(),
reason: v.string(),
},
handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedRole = (viewer.role ?? "").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.")
}
if (ticketDoc.activeSessionId) {
throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.")
}
const trimmedReason = reason.trim()
if (trimmedReason.length < 5) {
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.")
}
if (trimmedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).")
}
const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0)
const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0)
const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal)
const nextInternal = Math.max(0, Math.round(internalWorkedMs))
const nextExternal = Math.max(0, Math.round(externalWorkedMs))
const nextTotal = nextInternal + nextExternal
const deltaInternal = nextInternal - previousInternal
const deltaExternal = nextExternal - previousExternal
const deltaTotal = nextTotal - previousTotal
const now = Date.now()
await ctx.db.patch(ticketId, {
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
totalWorkedMs: nextTotal,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_ADJUSTED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl,
previousInternalMs: previousInternal,
previousExternalMs: previousExternal,
previousTotalMs: previousTotal,
nextInternalMs: nextInternal,
nextExternalMs: nextExternal,
nextTotalMs: nextTotal,
deltaInternalMs: deltaInternal,
deltaExternalMs: deltaExternal,
deltaTotalMs: deltaTotal,
},
createdAt: now,
})
const bodyHtml = [
"<p><strong>Ajuste manual de horas</strong></p>",
"<ul>",
`<li>Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} &rarr; ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
`<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} &rarr; ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
`<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} &rarr; ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})</li>`,
"</ul>",
`<p><strong>Motivo:</strong> ${escapeHtml(trimmedReason)}</p>`,
].join("")
const authorSnapshot: CommentAuthorSnapshot = {
name: viewer.user.name,
email: viewer.user.email,
avatarUrl: viewer.user.avatarUrl ?? undefined,
teams: viewer.user.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId,
authorId: actorId,
visibility: "INTERNAL",
body: bodyHtml,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now)
return {
ticketId,
totalWorkedMs: nextTotal,
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
serverNow: now,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
}
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
const trimmed = subject.trim();
if (trimmed.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
}
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateSummary = mutation({
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
}
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUMMARY_CHANGED",
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateCustomFields = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
fields: v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
),
},
handler: async (ctx, { ticketId, actorId, fields }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedRole = (viewer.role ?? "").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.")
}
const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? []
const previousRecord = mapCustomFieldsToRecord(previousEntries)
const sanitizedInputs: CustomFieldInput[] = fields
.filter((entry) => entry.value !== undefined)
.map((entry) => ({
fieldId: entry.fieldId,
value: entry.value,
}))
const normalized = await normalizeCustomFieldValues(
ctx,
ticketDoc.tenantId,
sanitizedInputs,
ticketDoc.formTemplate ?? null
)
const nextRecord = mapCustomFieldsToRecord(normalized)
const metaByFieldKey = new Map<
string,
{ fieldId?: Id<"ticketFields">; label: string; type: string }
>()
for (const entry of previousEntries) {
metaByFieldKey.set(entry.fieldKey, {
fieldId: entry.fieldId,
label: entry.label,
type: entry.type,
})
}
for (const entry of normalized) {
metaByFieldKey.set(entry.fieldKey, {
fieldId: entry.fieldId,
label: entry.label,
type: entry.type,
})
}
const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)]
const changedKeys = Array.from(new Set(keyOrder)).filter((key) => {
const previousEntry = getCustomFieldRecordEntry(previousRecord, key)
const nextEntry = getCustomFieldRecordEntry(nextRecord, key)
return !areCustomFieldEntriesEqual(previousEntry, nextEntry)
})
if (changedKeys.length === 0) {
return {
customFields: previousRecord,
updatedAt: ticketDoc.updatedAt ?? Date.now(),
}
}
const now = Date.now()
await ctx.db.patch(ticketId, {
customFields: normalized.length > 0 ? normalized : undefined,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "CUSTOM_FIELDS_UPDATED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl ?? undefined,
fields: changedKeys.map((fieldKey) => {
const meta = metaByFieldKey.get(fieldKey)
const previous = getCustomFieldRecordEntry(previousRecord, fieldKey)
const next = getCustomFieldRecordEntry(nextRecord, fieldKey)
return {
fieldId: meta?.fieldId,
fieldKey,
label: meta?.label ?? fieldKey,
type: meta?.type ?? "text",
previousValue: previous?.value ?? null,
nextValue: next?.value ?? null,
previousDisplayValue: previous?.displayValue ?? null,
nextDisplayValue: next?.displayValue ?? null,
changeType: !previous ? "added" : !next ? "removed" : "updated",
}
}),
},
createdAt: now,
})
return {
customFields: nextRecord,
updatedAt: now,
}
},
})
export const playNext = mutation({
args: {
tenantId: v.string(),
queueId: v.optional(v.id("queues")),
agentId: v.id("users"),
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
const { user: agent } = await requireStaff(ctx, agentId, tenantId)
// Find eligible tickets: not resolved/closed and not assigned
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<string, number> = { 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<string, Doc<"tickets">>()
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<string, unknown> = { 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,
}
},
})