Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1153 lines
37 KiB
TypeScript
1153 lines
37 KiB
TypeScript
import { mutation, query } from "./_generated/server";
|
|
import type { MutationCtx } from "./_generated/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Id, type Doc } from "./_generated/dataModel";
|
|
|
|
import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
|
|
|
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",
|
|
};
|
|
|
|
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 && normalized !== queue.name) {
|
|
return normalized;
|
|
}
|
|
if (queue.slug) {
|
|
const fromSlug = renameQueueString(queue.slug);
|
|
if (fromSlug) return fromSlug;
|
|
}
|
|
return normalized ?? queue.name;
|
|
}
|
|
|
|
function normalizeTeams(teams?: string[] | null): string[] {
|
|
if (!teams) return [];
|
|
return teams.map((team) => renameQueueString(team) ?? team);
|
|
}
|
|
|
|
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")),
|
|
search: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
if (!args.viewerId) {
|
|
return []
|
|
}
|
|
const { role } = await requireUser(ctx, args.viewerId, args.tenantId)
|
|
|
|
// Choose best index based on provided args for efficiency
|
|
let base: Doc<"tickets">[] = [];
|
|
if (args.status) {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
|
|
.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 {
|
|
base = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect();
|
|
}
|
|
let filtered = base;
|
|
|
|
if (role === "CUSTOMER") {
|
|
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
|
}
|
|
|
|
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
|
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
|
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 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 };
|
|
}
|
|
}
|
|
return {
|
|
id: t._id,
|
|
reference: t.reference,
|
|
tenantId: t.tenantId,
|
|
subject: t.subject,
|
|
summary: t.summary,
|
|
status: t.status,
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: normalizeTeams(requester.teams),
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: 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,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
}
|
|
: 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 { role } = await requireUser(ctx, viewerId, tenantId)
|
|
const t = await ctx.db.get(id);
|
|
if (!t || t.tenantId !== tenantId) return null;
|
|
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
|
throw new ConvexError("Acesso restrito ao solicitante")
|
|
}
|
|
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 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 timeline = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
|
.collect();
|
|
|
|
const customFieldsRecord = mapCustomFieldsToRecord(
|
|
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
|
);
|
|
|
|
const commentsHydrated = await Promise.all(
|
|
comments.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),
|
|
}))
|
|
);
|
|
return {
|
|
id: c._id,
|
|
author: {
|
|
id: author!._id,
|
|
name: author!.name,
|
|
email: author!.email,
|
|
avatarUrl: author!.avatarUrl,
|
|
teams: author!.teams ?? [],
|
|
},
|
|
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: t.status,
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: normalizeTeams(requester.teams),
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: 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,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
}
|
|
: null,
|
|
},
|
|
description: undefined,
|
|
customFields: customFieldsRecord,
|
|
timeline: timeline.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"),
|
|
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 { role } = await requireUser(ctx, args.actorId, args.tenantId)
|
|
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
|
|
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
|
}
|
|
|
|
const subject = args.subject.trim();
|
|
if (subject.length < 3) {
|
|
throw new ConvexError("Informe um assunto com pelo menos 3 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 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 id = await ctx.db.insert("tickets", {
|
|
tenantId: args.tenantId,
|
|
reference: nextRef,
|
|
subject,
|
|
summary: args.summary?.trim() || undefined,
|
|
status: "NEW",
|
|
priority: args.priority,
|
|
channel: args.channel,
|
|
queueId: args.queueId,
|
|
categoryId: args.categoryId,
|
|
subcategoryId: args.subcategoryId,
|
|
requesterId: args.requesterId,
|
|
assigneeId: undefined,
|
|
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,
|
|
});
|
|
const requester = await ctx.db.get(args.requesterId);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: id,
|
|
type: "CREATED",
|
|
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
|
|
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")
|
|
}
|
|
|
|
if (ticket.requesterId === args.authorId) {
|
|
await requireCustomer(ctx, args.authorId, ticket.tenantId)
|
|
if (args.visibility !== "PUBLIC") {
|
|
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
|
}
|
|
} else {
|
|
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
|
}
|
|
|
|
const now = Date.now();
|
|
const id = await ctx.db.insert("ticketComments", {
|
|
ticketId: args.ticketId,
|
|
authorId: args.authorId,
|
|
visibility: args.visibility,
|
|
body: args.body,
|
|
attachments: args.attachments ?? [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const author = await ctx.db.get(args.authorId);
|
|
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 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");
|
|
}
|
|
if (ticket.requesterId === actorId) {
|
|
await requireCustomer(ctx, actorId, ticket.tenantId)
|
|
} else {
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(commentId, {
|
|
body,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
|
|
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 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")
|
|
}
|
|
|
|
if (ticket.requesterId === actorId) {
|
|
await requireCustomer(ctx, actorId, ticket.tenantId)
|
|
} else {
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
|
|
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")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
|
const statusPt: Record<string, string> = {
|
|
NEW: "Novo",
|
|
OPEN: "Aberto",
|
|
PENDING: "Pendente",
|
|
ON_HOLD: "Em espera",
|
|
RESOLVED: "Resolvido",
|
|
CLOSED: "Fechado",
|
|
} as const;
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "STATUS_CHANGED",
|
|
payload: { to: status, toLabel: statusPt[status] ?? status, 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")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
|
if (!assignee || assignee.tenantId !== ticket.tenantId) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { assigneeId, 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")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
|
if (!queue || queue.tenantId !== ticket.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")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
|
|
if (categoryId === null) {
|
|
if (subcategoryId !== null) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
if (!ticket.categoryId && !ticket.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 !== ticket.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 !== ticket.tenantId) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
subcategoryName = subcategory.name
|
|
}
|
|
|
|
if (ticket.categoryId === categoryId && (ticket.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
|
|
return {
|
|
ticketId,
|
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
}
|
|
: 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") },
|
|
handler: async (ctx, { ticketId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
if (ticket.activeSessionId) {
|
|
return { status: "already_started", sessionId: ticket.activeSessionId }
|
|
}
|
|
|
|
const now = Date.now()
|
|
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
|
ticketId,
|
|
agentId: actorId,
|
|
startedAt: now,
|
|
})
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
working: true,
|
|
activeSessionId: sessionId,
|
|
updatedAt: now,
|
|
})
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_STARTED",
|
|
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId },
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "started", sessionId, startedAt: now }
|
|
},
|
|
})
|
|
|
|
export const pauseWork = 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 requireStaff(ctx, actorId, ticket.tenantId)
|
|
if (!ticket.activeSessionId) {
|
|
return { status: "already_paused" }
|
|
}
|
|
|
|
const session = await ctx.db.get(ticket.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(ticket.activeSessionId, {
|
|
stoppedAt: now,
|
|
durationMs,
|
|
})
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
|
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,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "paused", durationMs }
|
|
},
|
|
})
|
|
|
|
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)
|
|
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 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.status !== "CLOSED" && !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();
|
|
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, 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: chosen.status,
|
|
priority: chosen.priority,
|
|
channel: chosen.channel,
|
|
queue: queueName,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: normalizeTeams(requester.teams),
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: 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") },
|
|
handler: async (ctx, { ticketId }) => {
|
|
// 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;
|
|
},
|
|
});
|