sistema-de-chamados/convex/tickets.ts
codex-bot 5535ba81e6 feat: status + queue updates, filters e UI
- Status renomeados e cores (Em andamento azul, Pausado amarelo)
- Transições automáticas: iniciar=Em andamento, pausar=Pausado
- Fila padrão: Chamados ao criar ticket
- Admin/Empresas: renomeia ‘Slug’ → ‘Apelido’ + mensagens
- Dashboard: últimos tickets priorizam sem responsável (mais antigos)
- Tickets: filtro por responsável + salvar filtro por usuário
- Encerrar ticket: adiciona botão ‘Cancelar’
- Strings atualizadas (PDF, relatórios, badges)
2025-10-20 14:57:22 -03:00

1929 lines
65 KiB
TypeScript

import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento",
};
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
const STATUS_LABELS: Record<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",
};
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;
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;
}
}
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
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
}
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": {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: raw };
}
const parsed = Date.parse(String(raw));
if (!Number.isFinite(parsed)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: parsed };
}
case "boolean": {
if (typeof raw === "boolean") {
return { value: raw };
}
if (typeof raw === "string") {
const normalized = raw.toLowerCase();
if (normalized === "true" || normalized === "1") return { value: true };
if (normalized === "false" || normalized === "0") return { value: false };
}
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
}
case "select": {
if (!field.options || field.options.length === 0) {
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
}
const value = String(raw);
const option = field.options.find((opt) => opt.value === value);
if (!option) {
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
}
return { value: option.value, displayValue: option.label ?? option.value };
}
default:
return { value: raw };
}
}
async function normalizeCustomFieldValues(
ctx: Pick<MutationCtx, "db">,
tenantId: string,
inputs: CustomFieldInput[] | undefined
): Promise<NormalizedCustomField[]> {
const definitions = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (!definitions.length) {
if (inputs && inputs.length > 0) {
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
}
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 definitions.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) => {
acc[entry.fieldKey] = {
label: entry.label,
type: entry.type,
value: entry.value,
displayValue: entry.displayValue,
};
return acc;
}, {});
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
tenantId: v.string(),
status: v.optional(v.string()),
priority: v.optional(v.string()),
channel: v.optional(v.string()),
queueId: v.optional(v.id("queues")),
assigneeId: v.optional(v.id("users")),
requesterId: v.optional(v.id("users")),
search: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!args.viewerId) {
return []
}
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
// Managers are scoped to company; allow secondary narrowing by requester/assignee
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
} else if (args.assigneeId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
.collect();
} else if (args.requesterId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
.collect();
} else if (args.queueId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
.collect();
} else if (role === "COLLABORATOR") {
// Colaborador: exibir apenas tickets onde ele é o solicitante
// Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer
const all = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect()
const viewerEmail = user.email.trim().toLowerCase()
base = all.filter((t) => {
if (t.requesterId === args.viewerId) return true
const rs = t.requesterSnapshot as { email?: string } | undefined
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
return Boolean(email && email === viewerEmail)
})
} else {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect();
}
let filtered = base;
if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
}
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
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 (args.search) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
(t) =>
t.subject.toLowerCase().includes(term) ||
t.summary?.toLowerCase().includes(term) ||
`#${t.reference}`.toLowerCase().includes(term)
);
}
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
// hydrate requester and assignee
const result = await Promise.all(
limited.map(async (t) => {
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const queueName = normalizeQueueName(queue);
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null;
if (t.categoryId) {
if (!categoryCache.has(t.categoryId)) {
categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId));
}
const category = categoryCache.get(t.categoryId);
if (category) {
categorySummary = { id: category._id, name: category.name };
}
}
if (t.subcategoryId) {
if (!subcategoryCache.has(t.subcategoryId)) {
subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId));
}
const subcategory = subcategoryCache.get(t.subcategoryId);
if (subcategory) {
subcategorySummary = { id: subcategory._id, name: subcategory.name };
}
}
const serverNow = Date.now()
return {
id: t._id,
reference: t.reference,
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
: t.companyId || t.companySnapshot
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
: null,
requester: requester
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
: buildRequesterFromSnapshot(
t.requesterId,
t.requesterSnapshot ?? undefined,
{ ticketId: t._id }
),
assignee: t.assigneeId
? assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
firstResponseAt: t.firstResponseAt ?? null,
resolvedAt: t.resolvedAt ?? null,
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
category: categorySummary,
subcategory: subcategorySummary,
workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: t.internalWorkedMs ?? 0,
externalWorkedMs: t.externalWorkedMs ?? 0,
serverNow,
activeSession: activeSession
? {
id: activeSession._id,
agentId: activeSession.agentId,
startedAt: activeSession.startedAt,
workType: activeSession.workType ?? "INTERNAL",
}
: null,
},
};
})
);
// sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
},
});
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { tenantId, id, viewerId }) => {
const { user, role } = await requireUser(ctx, viewerId, tenantId)
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
if (role === "COLLABORATOR") {
const isOwnerById = String(t.requesterId) === String(viewerId)
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
const viewerEmail = user.email.trim().toLowerCase()
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
if (!isOwnerById && !isOwnerByEmail) {
return null
}
}
// no customer role; managers are constrained to company via ensureManagerTicketAccess
let requester: Doc<"users"> | null = null
if (role === "MANAGER") {
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
}
if (!requester) {
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
}
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
const 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;
return {
id: t._id,
reference: t.reference,
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
company: company
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
: t.companyId || t.companySnapshot
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
: null,
requester: requester
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
: buildRequesterFromSnapshot(
t.requesterId,
t.requesterSnapshot ?? undefined,
{ ticketId: t._id }
),
assignee: t.assigneeId
? assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: normalizeTeams(assignee.teams),
}
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
firstResponseAt: t.firstResponseAt ?? null,
resolvedAt: t.resolvedAt ?? null,
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
category: 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,
},
description: undefined,
customFields: customFieldsRecord,
timeline: timelineRecords.map((ev) => {
let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
payload = { ...payload, queueName: normalized };
}
}
return {
id: ev._id,
type: ev.type,
payload,
createdAt: ev.createdAt,
};
}),
comments: commentsHydrated,
};
},
});
export const create = mutation({
args: {
actorId: v.id("users"),
tenantId: v.string(),
subject: v.string(),
summary: v.optional(v.string()),
priority: v.string(),
channel: v.string(),
queueId: v.optional(v.id("queues")),
requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
},
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")
}
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// 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 = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
const requesterSnapshot = {
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
const companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
const companySnapshot = companyDoc
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const assigneeSnapshot = initialAssignee
? {
name: initialAssignee.name,
email: initialAssignee.email,
avatarUrl: initialAssignee.avatarUrl ?? undefined,
teams: initialAssignee.teams ?? undefined,
}
: undefined
// default queue: if none provided, prefer "Chamados"
let resolvedQueueId = args.queueId as Id<"queues"> | undefined
if (!resolvedQueueId) {
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect()
const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
if (preferred) {
resolvedQueueId = preferred._id as Id<"queues">
}
}
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject,
summary: args.summary?.trim() || undefined,
status: initialStatus,
priority: args.priority,
channel: args.channel,
queueId: resolvedQueueId,
categoryId: args.categoryId,
subcategoryId: args.subcategoryId,
requesterId: args.requesterId,
requesterSnapshot,
assigneeId: initialAssigneeId,
assigneeSnapshot,
companyId: requester.companyId ?? undefined,
companySnapshot,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
createdAt: now,
updatedAt: now,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
tags: [],
slaPolicyId: undefined,
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
createdAt: now,
});
if (initialAssigneeId && initialAssignee) {
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId },
createdAt: now,
})
}
return id;
},
});
export const addComment = mutation({
args: {
ticketId: v.id("tickets"),
authorId: v.id("users"),
visibility: v.string(),
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
},
handler: async (ctx, args) => {
const ticket = await ctx.db.get(args.ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
if (!author || author.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
const requestedVisibility = (args.visibility ?? "").toUpperCase()
if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
throw new ConvexError("Visibilidade inválida")
}
if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, author, ticketDoc)
if (requestedVisibility !== "PUBLIC") {
throw new ConvexError("Gestores só podem registrar comentários públicos")
}
}
const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
}
// Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável.
// O solicitante (colaborador) pode comentar sempre.
const isRequester = String(ticketDoc.requesterId) === String(author._id)
const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
const hasAssignee = Boolean(ticketDoc.assigneeId)
// Gestores podem comentar mesmo sem responsável; admin/agent só com responsável
if (!isRequester && isAdminOrAgent && !hasAssignee) {
throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.")
}
if (ticketDoc.requesterId === args.authorId) {
// O próprio solicitante pode comentar seu ticket.
// Comentários internos já são bloqueados acima para quem não é STAFF.
// Portanto, nada a fazer aqui.
} else {
await requireTicketStaff(ctx, args.authorId, ticketDoc)
}
const attachments = args.attachments ?? []
if (attachments.length > 5) {
throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
}
const maxAttachmentSize = 5 * 1024 * 1024
for (const attachment of attachments) {
if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
throw new ConvexError("Cada anexo pode ter até 5MB")
}
}
const bodyPlainLen = plainTextLength(args.body)
if (bodyPlainLen > MAX_COMMENT_CHARS) {
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
}
const authorSnapshot: CommentAuthorSnapshot = {
name: author.name,
email: author.email,
avatarUrl: author.avatarUrl ?? undefined,
teams: author.teams ?? undefined,
};
const now = Date.now();
const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId,
authorId: args.authorId,
visibility: requestedVisibility,
body: args.body,
authorSnapshot,
attachments,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("ticketEvents", {
ticketId: args.ticketId,
type: "COMMENT_ADDED",
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
createdAt: now,
});
// bump ticket updatedAt
await ctx.db.patch(args.ticketId, { updatedAt: now });
return id;
},
});
export const updateComment = mutation({
args: {
ticketId: v.id("tickets"),
commentId: v.id("ticketComments"),
actorId: v.id("users"),
body: v.string(),
},
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (ticketDoc.requesterId === actorId) {
if (STAFF_ROLES.has(normalizedRole)) {
await requireTicketStaff(ctx, actorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para editar")
}
} else {
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const bodyPlainLen = plainTextLength(body)
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,
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)
const now = Date.now();
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
createdAt: now,
});
},
});
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, 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 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 currentAssigneeId = ticketDoc.assigneeId ?? null
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
}
const now = Date.now();
const assigneeSnapshot = {
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl ?? undefined,
teams: assignee.teams ?? undefined,
}
await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: assignee.name, actorId },
createdAt: now,
});
},
});
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a fila do chamado")
}
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queueName = normalizeQueueName(queue);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
payload: { queueId, queueName, actorId },
createdAt: now,
});
},
});
export const updateCategories = mutation({
args: {
ticketId: v.id("tickets"),
categoryId: v.union(v.id("ticketCategories"), v.null()),
subcategoryId: v.union(v.id("ticketSubcategories"), v.null()),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
}
if (categoryId === null) {
if (subcategoryId !== null) {
throw new ConvexError("Subcategoria inválida")
}
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
return { status: "unchanged" }
}
const now = Date.now()
await ctx.db.patch(ticketId, {
categoryId: undefined,
subcategoryId: undefined,
updatedAt: now,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: "CATEGORY_CHANGED",
payload: {
categoryId: null,
categoryName: null,
subcategoryId: null,
subcategoryName: null,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
})
return { status: "cleared" }
}
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Categoria inválida")
}
let subcategoryName: string | null = null
if (subcategoryId !== null) {
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Subcategoria inválida")
}
subcategoryName = subcategory.name
}
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
return { status: "unchanged" }
}
const now = Date.now()
await ctx.db.patch(ticketId, {
categoryId,
subcategoryId: subcategoryId ?? undefined,
updatedAt: now,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: "CATEGORY_CHANGED",
payload: {
categoryId,
categoryName: category.name,
subcategoryId,
subcategoryName,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
})
return { status: "updated" }
},
})
export const workSummary = query({
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { ticketId, viewerId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) return null
await requireStaff(ctx, viewerId, ticket.tenantId)
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
const serverNow = Date.now()
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,
}
},
})
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 isAdmin = viewer.role === "ADMIN"
const currentAssigneeId = ticketDoc.assigneeId ?? null
const now = Date.now()
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
}
if (ticketDoc.activeSessionId) {
const session = await ctx.db.get(ticketDoc.activeSessionId)
return {
status: "already_started",
sessionId: ticketDoc.activeSessionId,
startedAt: session?.startedAt ?? now,
serverNow: now,
}
}
let assigneePatched = false
if (!currentAssigneeId) {
const assigneeSnapshot = {
name: viewer.user.name,
email: viewer.user.email,
avatarUrl: viewer.user.avatarUrl ?? undefined,
teams: viewer.user.teams ?? undefined,
}
await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now })
ticketDoc.assigneeId = actorId
assigneePatched = true
}
const sessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: actorId,
workType: (workType ?? "INTERNAL").toUpperCase(),
startedAt: now,
})
await ctx.db.patch(ticketId, {
working: true,
activeSessionId: sessionId,
status: "AWAITING_ATTENDANCE",
updatedAt: now,
})
if (assigneePatched) {
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId: actorId, assigneeName: viewer.user.name, actorId },
createdAt: now,
})
}
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl,
sessionId,
workType: (workType ?? "INTERNAL").toUpperCase(),
},
createdAt: now,
})
return { status: "started", sessionId, startedAt: now, serverNow: now }
},
})
export const pauseWork = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
reason: v.string(),
note: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, reason, note }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const isAdmin = viewer.role === "ADMIN"
if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode pausar este chamado")
}
if (!ticketDoc.activeSessionId) {
return { status: "already_paused" }
}
if (!PAUSE_REASON_LABELS[reason]) {
throw new ConvexError("Motivo de pausa inválido")
}
const session = await ctx.db.get(ticketDoc.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
return { status: "session_missing" }
}
const now = Date.now()
const durationMs = now - session.startedAt
await ctx.db.patch(ticketDoc.activeSessionId, {
stoppedAt: now,
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
})
const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
await ctx.db.patch(ticketId, {
working: false,
activeSessionId: undefined,
status: "PAUSED",
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now,
})
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_PAUSED",
payload: {
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
workType: sessionType,
pauseReason: reason,
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
pauseNote: note ?? "",
},
createdAt: now,
})
return {
status: "paused",
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
serverNow: now,
}
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
const trimmed = subject.trim();
if (trimmed.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
}
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateSummary = mutation({
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
}
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUMMARY_CHANGED",
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const playNext = mutation({
args: {
tenantId: v.string(),
queueId: v.optional(v.id("queues")),
agentId: v.id("users"),
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
const { user: agent } = await requireStaff(ctx, agentId, tenantId)
// Find eligible tickets: not resolved/closed and not assigned
let candidates: Doc<"tickets">[] = []
if (queueId) {
candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.collect()
} else {
candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
candidates = candidates.filter(
(t) => t.status !== "RESOLVED" && !t.assigneeId
);
if (candidates.length === 0) return null;
// prioritize by priority then createdAt
const rank: Record<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 === "PENDING" ? "AWAITING_ATTENDANCE" : 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, 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,
}
},
})