sistema-de-chamados/web/convex/tickets.ts
esdrasrenan ea60c3b841 feat(rich-text, types): Tiptap editor, SSR-safe, comments + description; stricter typing (no any) across app
- 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
2025-10-04 14:25:10 -03:00

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,
}
},
});