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
This commit is contained in:
esdrasrenan 2025-10-04 14:25:10 -03:00
parent 9b0c0bd80a
commit ea60c3b841
26 changed files with 1390 additions and 245 deletions

View file

@ -41,6 +41,7 @@ export default defineSchema({
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
description: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
@ -59,7 +60,8 @@ export default defineSchema({
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"]),
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant", ["tenantId"]),
ticketComments: defineTable({
ticketId: v.id("tickets"),
@ -87,4 +89,3 @@ export default defineSchema({
createdAt: v.number(),
}).index("by_ticket", ["ticketId"]),
});

View file

@ -1,8 +1,8 @@
import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";
import { Id, type Doc } from "./_generated/dataModel";
const STATUS_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
export const list = query({
args: {
@ -15,16 +15,28 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
let q = ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId));
const all = await q.collect();
let filtered = all;
if (args.status) filtered = filtered.filter((t) => t.status === args.status);
// 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.queueId) filtered = filtered.filter((t) => t.queueId === args.queueId);
if (args.search) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
@ -38,9 +50,9 @@ export const list = query({
// hydrate requester and assignee
const result = await Promise.all(
limited.map(async (t) => {
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : 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;
return {
id: t._id,
reference: t.reference,
@ -80,7 +92,7 @@ export const list = query({
})
);
// sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any));
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
},
});
@ -89,9 +101,9 @@ export const getById = query({
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);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : 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))
@ -103,7 +115,7 @@ export const getById = query({
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = await ctx.db.get(c.authorId);
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,
@ -296,7 +308,7 @@ export const changeAssignee = mutation({
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);
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
@ -311,7 +323,7 @@ export const changeQueue = mutation({
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);
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
@ -329,10 +341,18 @@ export const playNext = mutation({
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
// Find eligible tickets: not resolved/closed and not assigned
let candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any))
.collect();
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
@ -341,17 +361,18 @@ export const playNext = mutation({
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 = STATUS_ORDER.indexOf(a.priority as any);
const pb = STATUS_ORDER.indexOf(b.priority as any);
if (pa !== pb) return pa - pb;
return a.createdAt - b.createdAt;
});
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);
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
@ -359,51 +380,45 @@ export const playNext = mutation({
createdAt: now,
});
return await getPublicById(ctx, chosen._id);
// 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,
}
},
});
// internal helper to hydrate a ticket in the same shape as list/getById
const getPublicById = async (ctx: any, id: Id<"tickets">) => {
const t = await ctx.db.get(id);
if (!t) return null;
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : 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,
};
};