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:
parent
9b0c0bd80a
commit
ea60c3b841
26 changed files with 1390 additions and 245 deletions
|
|
@ -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"]),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue