feat: núcleo de tickets com Convex (CRUD, play, comentários com anexos) + auth placeholder; docs em AGENTS.md; toasts e updates otimistas; mapeadores Zod; refinos PT-BR e layout do painel de detalhes
This commit is contained in:
parent
2230590e57
commit
27b103cb46
97 changed files with 15117 additions and 15715 deletions
368
web/convex/tickets.ts
Normal file
368
web/convex/tickets.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
const STATUS_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) => {
|
||||
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);
|
||||
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(
|
||||
(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);
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
// sort by updatedAt desc
|
||||
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any));
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
|
||||
const queue = t.queueId ? await ctx.db.get(t.queueId) : 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);
|
||||
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,
|
||||
});
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
type: "CREATED",
|
||||
payload: { requesterId: args.requesterId },
|
||||
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,
|
||||
});
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: args.ticketId,
|
||||
type: "COMMENT_ADDED",
|
||||
payload: { authorId: args.authorId },
|
||||
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 });
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "STATUS_CHANGED",
|
||||
payload: { to: status, 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 = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any))
|
||||
.collect();
|
||||
|
||||
candidates = candidates.filter(
|
||||
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// prioritize by priority then createdAt
|
||||
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 chosen = candidates[0];
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: chosen._id,
|
||||
type: "ASSIGNEE_CHANGED",
|
||||
payload: { assigneeId: agentId },
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return await getPublicById(ctx, chosen._id);
|
||||
},
|
||||
});
|
||||
|
||||
// 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