sistema-de-chamados/web/convex/tickets.ts
esdrasrenan 07ff117a67 feat: enable ticket comment editing
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-10-05 02:08:20 -03:00

873 lines
28 KiB
TypeScript

import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
"Suporte N2": "Laboratório",
"suporte-n2": "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);
}
export const list = query({
args: {
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) => {
// 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 (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") },
handler: async (ctx, { tenantId, id }) => {
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
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 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: {},
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: {
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"),
},
handler: async (ctx, args) => {
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");
}
// 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,
});
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 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 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 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 comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
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 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 now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: user?.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 now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
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.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
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 category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticket.tenantId) {
throw new ConvexError("Categoria inválida")
}
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
throw new ConvexError("Subcategoria inválida")
}
if (ticket.categoryId === categoryId && ticket.subcategoryId === subcategoryId) {
return { status: "unchanged" }
}
const now = Date.now()
await ctx.db.patch(ticketId, {
categoryId,
subcategoryId,
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: subcategory.name,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
})
return { status: "updated" }
},
})
export const workSummary = query({
args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) return null
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 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")
}
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")
}
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) return;
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) return;
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 }) => {
// 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 });
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
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;
},
});