- Add Tiptap editor + toolbar and rich content rendering with sanitize-html - Fix SSR hydration (immediatelyRender: false) and setContent options - Comments: rich text + visibility selector, typed attachments (Id<_storage>) - New Ticket: description rich text; attachments typed; queues typed - Convex: server-side filters using indexes; priority order rename; stronger Doc/Id typing; remove helper with any - Schemas/Mappers: zod v4 record typing; event payload record typing; customFields typed - UI: replace any in header/play/list/timeline/fields; improve select typings - Build passes; only non-blocking lint warnings remain
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { internalMutation, mutation, query } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import { Id, type Doc } from "./_generated/dataModel";
|
|
|
|
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
|
|
|
|
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;
|
|
// 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;
|
|
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: queue?.name ?? null,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: requester.teams ?? [],
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: 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,
|
|
};
|
|
})
|
|
);
|
|
// 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 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,
|
|
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,
|
|
};
|
|
})
|
|
);
|
|
|
|
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: queue?.name ?? null,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: requester.teams ?? [],
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: 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,
|
|
description: undefined,
|
|
customFields: {},
|
|
timeline: timeline.map((ev) => ({
|
|
id: ev._id,
|
|
type: ev.type,
|
|
payload: ev.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"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// 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: args.subject,
|
|
summary: args.summary,
|
|
status: "NEW",
|
|
priority: args.priority,
|
|
channel: args.channel,
|
|
queueId: args.queueId,
|
|
requesterId: args.requesterId,
|
|
assigneeId: undefined,
|
|
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 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;
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "QUEUE_CHANGED",
|
|
payload: { queueId, queueName: queue?.name, actorId },
|
|
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
|
|
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: queue?.name ?? null,
|
|
requester: requester && {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: requester.teams ?? [],
|
|
},
|
|
assignee: assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: 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,
|
|
}
|
|
},
|
|
});
|