feat: seed real agents and enable comment templates
This commit is contained in:
parent
df8c4e29bb
commit
409cbea7b9
13 changed files with 1722 additions and 29 deletions
4
web/convex/_generated/api.d.ts
vendored
4
web/convex/_generated/api.d.ts
vendored
|
|
@ -10,9 +10,11 @@
|
|||
|
||||
import type * as bootstrap from "../bootstrap.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as commentTemplates from "../commentTemplates.js";
|
||||
import type * as fields from "../fields.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as migrations from "../migrations.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
import type * as reports from "../reports.js";
|
||||
|
|
@ -39,9 +41,11 @@ import type {
|
|||
declare const fullApi: ApiFromModules<{
|
||||
bootstrap: typeof bootstrap;
|
||||
categories: typeof categories;
|
||||
commentTemplates: typeof commentTemplates;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
invites: typeof invites;
|
||||
migrations: typeof migrations;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
reports: typeof reports;
|
||||
|
|
|
|||
173
web/convex/commentTemplates.ts
Normal file
173
web/convex/commentTemplates.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import sanitizeHtml from "sanitize-html"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { requireStaff } from "./rbac"
|
||||
|
||||
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
"p",
|
||||
"br",
|
||||
"a",
|
||||
"strong",
|
||||
"em",
|
||||
"u",
|
||||
"s",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"code",
|
||||
"pre",
|
||||
"span",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "name", "target", "rel"],
|
||||
span: ["style"],
|
||||
code: ["class"],
|
||||
pre: ["class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
||||
},
|
||||
allowVulnerableTags: false,
|
||||
}
|
||||
|
||||
function sanitizeTemplateBody(body: string) {
|
||||
const sanitized = sanitizeHtml(body || "", SANITIZE_OPTIONS).trim()
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string) {
|
||||
return title?.trim()
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
await requireStaff(ctx, viewerId, tenantId)
|
||||
const templates = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
return templates
|
||||
.sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" }))
|
||||
.map((template) => ({
|
||||
id: template._id,
|
||||
title: template.title,
|
||||
body: template.body,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt,
|
||||
createdBy: template.createdBy,
|
||||
updatedBy: template.updatedBy ?? null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, title, body }) => {
|
||||
await requireStaff(ctx, actorId, tenantId)
|
||||
const normalizedTitle = normalizeTitle(title)
|
||||
if (!normalizedTitle || normalizedTitle.length < 3) {
|
||||
throw new ConvexError("Informe um título válido para o template")
|
||||
}
|
||||
const sanitizedBody = sanitizeTemplateBody(body)
|
||||
if (!sanitizedBody) {
|
||||
throw new ConvexError("Informe o conteúdo do template")
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
|
||||
.first()
|
||||
|
||||
if (existing) {
|
||||
throw new ConvexError("Já existe um template com este título")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const id = await ctx.db.insert("commentTemplates", {
|
||||
tenantId,
|
||||
title: normalizedTitle,
|
||||
body: sanitizedBody,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return id
|
||||
},
|
||||
})
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
templateId: v.id("commentTemplates"),
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, { templateId, tenantId, actorId, title, body }) => {
|
||||
await requireStaff(ctx, actorId, tenantId)
|
||||
const template = await ctx.db.get(templateId)
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
throw new ConvexError("Template não encontrado")
|
||||
}
|
||||
|
||||
const normalizedTitle = normalizeTitle(title)
|
||||
if (!normalizedTitle || normalizedTitle.length < 3) {
|
||||
throw new ConvexError("Informe um título válido para o template")
|
||||
}
|
||||
const sanitizedBody = sanitizeTemplateBody(body)
|
||||
if (!sanitizedBody) {
|
||||
throw new ConvexError("Informe o conteúdo do template")
|
||||
}
|
||||
|
||||
const duplicate = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
|
||||
.first()
|
||||
if (duplicate && duplicate._id !== templateId) {
|
||||
throw new ConvexError("Já existe um template com este título")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(templateId, {
|
||||
title: normalizedTitle,
|
||||
body: sanitizedBody,
|
||||
updatedBy: actorId,
|
||||
updatedAt: now,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
templateId: v.id("commentTemplates"),
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { templateId, tenantId, actorId }) => {
|
||||
await requireStaff(ctx, actorId, tenantId)
|
||||
const template = await ctx.db.get(templateId)
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
throw new ConvexError("Template não encontrado")
|
||||
}
|
||||
await ctx.db.delete(templateId)
|
||||
},
|
||||
})
|
||||
488
web/convex/migrations.ts
Normal file
488
web/convex/migrations.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
|
||||
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
type ImportedUser = {
|
||||
email: string
|
||||
name: string
|
||||
role?: string | null
|
||||
avatarUrl?: string | null
|
||||
teams?: string[] | null
|
||||
}
|
||||
|
||||
type ImportedQueue = {
|
||||
slug?: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
function normalizeRole(role: string | null | undefined) {
|
||||
if (!role) return "AGENT"
|
||||
const normalized = role.toUpperCase()
|
||||
if (STAFF_ROLES.has(normalized)) return normalized
|
||||
if (normalized === "CUSTOMER") return "CUSTOMER"
|
||||
return "AGENT"
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
|
||||
for (const key of Object.keys(input)) {
|
||||
if (input[key] === undefined) {
|
||||
delete input[key]
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
async function ensureUser(
|
||||
ctx: MutationCtx,
|
||||
tenantId: string,
|
||||
data: ImportedUser,
|
||||
cache: Map<string, Id<"users">>
|
||||
) {
|
||||
if (cache.has(data.email)) {
|
||||
return cache.get(data.email)!
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email))
|
||||
.first()
|
||||
|
||||
const role = normalizeRole(data.role)
|
||||
const record = existing
|
||||
? (() => {
|
||||
const needsPatch =
|
||||
existing.name !== data.name ||
|
||||
existing.role !== role ||
|
||||
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
|
||||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? [])
|
||||
if (needsPatch) {
|
||||
return ctx.db.patch(existing._id, {
|
||||
name: data.name,
|
||||
role,
|
||||
avatarUrl: data.avatarUrl ?? undefined,
|
||||
teams: data.teams ?? undefined,
|
||||
tenantId,
|
||||
})
|
||||
}
|
||||
return Promise.resolve()
|
||||
})()
|
||||
: ctx.db.insert("users", {
|
||||
tenantId,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role,
|
||||
avatarUrl: data.avatarUrl ?? undefined,
|
||||
teams: data.teams ?? undefined,
|
||||
})
|
||||
|
||||
const id = existing ? existing._id : ((await record) as Id<"users">)
|
||||
cache.set(data.email, id)
|
||||
return id
|
||||
}
|
||||
|
||||
async function ensureQueue(
|
||||
ctx: MutationCtx,
|
||||
tenantId: string,
|
||||
data: ImportedQueue,
|
||||
cache: Map<string, Id<"queues">>
|
||||
) {
|
||||
const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name)
|
||||
if (cache.has(slug)) return cache.get(slug)!
|
||||
|
||||
const bySlug = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
||||
.first()
|
||||
if (bySlug) {
|
||||
if (bySlug.name !== data.name) {
|
||||
await ctx.db.patch(bySlug._id, { name: data.name })
|
||||
}
|
||||
cache.set(slug, bySlug._id)
|
||||
return bySlug._id
|
||||
}
|
||||
|
||||
const byName = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.filter((q) => q.eq(q.field("name"), data.name))
|
||||
.first()
|
||||
if (byName) {
|
||||
if (byName.slug !== slug) {
|
||||
await ctx.db.patch(byName._id, { slug })
|
||||
}
|
||||
cache.set(slug, byName._id)
|
||||
return byName._id
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("queues", {
|
||||
tenantId,
|
||||
name: data.name,
|
||||
slug,
|
||||
teamId: undefined,
|
||||
})
|
||||
cache.set(slug, id)
|
||||
return id
|
||||
}
|
||||
|
||||
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
}
|
||||
|
||||
export const exportTenantSnapshot = query({
|
||||
args: {
|
||||
secret: v.string(),
|
||||
tenantId: v.string(),
|
||||
},
|
||||
handler: async (ctx, { secret, tenantId }) => {
|
||||
if (secret !== SECRET) {
|
||||
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
||||
}
|
||||
|
||||
const [users, queues] = await Promise.all([getTenantUsers(ctx, tenantId), getTenantQueues(ctx, tenantId)])
|
||||
|
||||
const userMap = new Map(users.map((user) => [user._id, user]))
|
||||
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
|
||||
|
||||
const tickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
const ticketsWithRelations = []
|
||||
|
||||
for (const ticket of tickets) {
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
||||
.collect()
|
||||
|
||||
const events = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
||||
.collect()
|
||||
|
||||
const requester = userMap.get(ticket.requesterId)
|
||||
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
|
||||
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
|
||||
|
||||
if (!requester) {
|
||||
continue
|
||||
}
|
||||
|
||||
ticketsWithRelations.push({
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
summary: ticket.summary ?? null,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
channel: ticket.channel,
|
||||
queueSlug: queue?.slug ?? undefined,
|
||||
requesterEmail: requester.email,
|
||||
assigneeEmail: assignee?.email ?? undefined,
|
||||
dueAt: ticket.dueAt ?? undefined,
|
||||
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
||||
resolvedAt: ticket.resolvedAt ?? undefined,
|
||||
closedAt: ticket.closedAt ?? undefined,
|
||||
createdAt: ticket.createdAt,
|
||||
updatedAt: ticket.updatedAt,
|
||||
tags: ticket.tags ?? [],
|
||||
comments: comments
|
||||
.map((comment) => {
|
||||
const author = userMap.get(comment.authorId)
|
||||
if (!author) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
authorEmail: author.email,
|
||||
visibility: comment.visibility,
|
||||
body: comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
}
|
||||
})
|
||||
.filter((value): value is {
|
||||
authorEmail: string
|
||||
visibility: string
|
||||
body: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
} => value !== null),
|
||||
events: events.map((event) => ({
|
||||
type: event.type,
|
||||
payload: event.payload ?? {},
|
||||
createdAt: event.createdAt,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
users: users.map((user) => ({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
teams: user.teams ?? [],
|
||||
})),
|
||||
queues: queues.map((queue) => ({
|
||||
name: queue.name,
|
||||
slug: queue.slug,
|
||||
})),
|
||||
tickets: ticketsWithRelations,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const importPrismaSnapshot = mutation({
|
||||
args: {
|
||||
secret: v.string(),
|
||||
snapshot: v.object({
|
||||
tenantId: v.string(),
|
||||
users: v.array(
|
||||
v.object({
|
||||
email: v.string(),
|
||||
name: v.string(),
|
||||
role: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
queues: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
slug: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
tickets: v.array(
|
||||
v.object({
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
status: v.string(),
|
||||
priority: v.string(),
|
||||
channel: v.string(),
|
||||
queueSlug: v.optional(v.string()),
|
||||
requesterEmail: v.string(),
|
||||
assigneeEmail: v.optional(v.string()),
|
||||
dueAt: v.optional(v.number()),
|
||||
firstResponseAt: v.optional(v.number()),
|
||||
resolvedAt: v.optional(v.number()),
|
||||
closedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
comments: v.array(
|
||||
v.object({
|
||||
authorEmail: v.string(),
|
||||
visibility: v.string(),
|
||||
body: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
),
|
||||
events: v.array(
|
||||
v.object({
|
||||
type: v.string(),
|
||||
payload: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, { secret, snapshot }) => {
|
||||
if (!SECRET) {
|
||||
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
||||
}
|
||||
if (secret !== SECRET) {
|
||||
throw new ConvexError("Segredo inválido para sincronização")
|
||||
}
|
||||
|
||||
const userCache = new Map<string, Id<"users">>()
|
||||
const queueCache = new Map<string, Id<"queues">>()
|
||||
|
||||
for (const user of snapshot.users) {
|
||||
await ensureUser(ctx, snapshot.tenantId, user, userCache)
|
||||
}
|
||||
|
||||
for (const queue of snapshot.queues) {
|
||||
await ensureQueue(ctx, snapshot.tenantId, queue, queueCache)
|
||||
}
|
||||
|
||||
const snapshotStaffEmails = new Set(
|
||||
snapshot.users
|
||||
.filter((user) => normalizeRole(user.role ?? null) !== "CUSTOMER")
|
||||
.map((user) => normalizeEmail(user.email))
|
||||
)
|
||||
|
||||
const existingTenantUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId))
|
||||
.collect()
|
||||
|
||||
for (const user of existingTenantUsers) {
|
||||
const role = normalizeRole(user.role ?? null)
|
||||
if (STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
|
||||
await ctx.db.delete(user._id)
|
||||
}
|
||||
}
|
||||
|
||||
let ticketsUpserted = 0
|
||||
let commentsInserted = 0
|
||||
let eventsInserted = 0
|
||||
|
||||
for (const ticket of snapshot.tickets) {
|
||||
const requesterId = await ensureUser(
|
||||
ctx,
|
||||
snapshot.tenantId,
|
||||
{
|
||||
email: ticket.requesterEmail,
|
||||
name: ticket.requesterEmail,
|
||||
},
|
||||
userCache
|
||||
)
|
||||
const assigneeId = ticket.assigneeEmail
|
||||
? await ensureUser(
|
||||
ctx,
|
||||
snapshot.tenantId,
|
||||
{
|
||||
email: ticket.assigneeEmail,
|
||||
name: ticket.assigneeEmail,
|
||||
},
|
||||
userCache
|
||||
)
|
||||
: undefined
|
||||
|
||||
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference))
|
||||
.first()
|
||||
|
||||
const payload = pruneUndefined({
|
||||
tenantId: snapshot.tenantId,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
summary: ticket.summary ?? undefined,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
channel: ticket.channel,
|
||||
queueId: queueId as Id<"queues"> | undefined,
|
||||
categoryId: undefined,
|
||||
subcategoryId: undefined,
|
||||
requesterId,
|
||||
assigneeId: assigneeId as Id<"users"> | undefined,
|
||||
working: false,
|
||||
slaPolicyId: undefined,
|
||||
dueAt: ticket.dueAt ?? undefined,
|
||||
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
||||
resolvedAt: ticket.resolvedAt ?? undefined,
|
||||
closedAt: ticket.closedAt ?? undefined,
|
||||
updatedAt: ticket.updatedAt,
|
||||
createdAt: ticket.createdAt,
|
||||
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
|
||||
customFields: undefined,
|
||||
totalWorkedMs: undefined,
|
||||
activeSessionId: undefined,
|
||||
})
|
||||
|
||||
let ticketId: Id<"tickets">
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, payload)
|
||||
ticketId = existing._id
|
||||
} else {
|
||||
ticketId = await ctx.db.insert("tickets", payload)
|
||||
}
|
||||
|
||||
ticketsUpserted += 1
|
||||
|
||||
const existingComments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||
.collect()
|
||||
for (const comment of existingComments) {
|
||||
await ctx.db.delete(comment._id)
|
||||
}
|
||||
|
||||
const existingEvents = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||
.collect()
|
||||
for (const event of existingEvents) {
|
||||
await ctx.db.delete(event._id)
|
||||
}
|
||||
|
||||
for (const comment of ticket.comments) {
|
||||
const authorId = await ensureUser(
|
||||
ctx,
|
||||
snapshot.tenantId,
|
||||
{
|
||||
email: comment.authorEmail,
|
||||
name: comment.authorEmail,
|
||||
},
|
||||
userCache
|
||||
)
|
||||
await ctx.db.insert("ticketComments", {
|
||||
ticketId,
|
||||
authorId,
|
||||
visibility: comment.visibility,
|
||||
body: comment.body,
|
||||
attachments: [],
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
})
|
||||
commentsInserted += 1
|
||||
}
|
||||
|
||||
for (const event of ticket.events) {
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: event.type,
|
||||
payload: event.payload ?? {},
|
||||
createdAt: event.createdAt,
|
||||
})
|
||||
eventsInserted += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usersProcessed: userCache.size,
|
||||
queuesProcessed: queueCache.size,
|
||||
ticketsUpserted,
|
||||
commentsInserted,
|
||||
eventsInserted,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -107,6 +107,18 @@ export default defineSchema({
|
|||
createdAt: v.number(),
|
||||
}).index("by_ticket", ["ticketId"]),
|
||||
|
||||
commentTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
createdBy: v.id("users"),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_title", ["tenantId", "title"]),
|
||||
|
||||
ticketWorkSessions: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
agentId: v.id("users"),
|
||||
|
|
|
|||
|
|
@ -61,14 +61,71 @@ export const seedDemo = mutation({
|
|||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||
.first();
|
||||
if (found) return found._id;
|
||||
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` });
|
||||
if (found) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (found.name !== name) updates.name = name;
|
||||
if ((found.role ?? "AGENT") !== role) updates.role = role;
|
||||
const desiredAvatar = role === "CUSTOMER" ? found.avatarUrl ?? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}`;
|
||||
if (found.avatarUrl !== desiredAvatar) updates.avatarUrl = desiredAvatar;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await ctx.db.patch(found._id, updates);
|
||||
}
|
||||
return found._id;
|
||||
}
|
||||
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: role === "CUSTOMER" ? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}` });
|
||||
}
|
||||
const reverId = await ensureUser("Rever", "renan.pac@paulicon.com.br");
|
||||
const agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev");
|
||||
const adminId = await ensureUser("Administrador", "admin@sistema.dev", "ADMIN");
|
||||
const staffRoster = [
|
||||
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
|
||||
{ name: "George Araujo", email: "george.araujo@rever.com.br" },
|
||||
{ name: "Hugo Soares", email: "hugo.soares@rever.com.br" },
|
||||
{ name: "Julio Cesar", email: "julio@rever.com.br" },
|
||||
{ name: "Lorena Magalhães", email: "lorena@rever.com.br" },
|
||||
{ name: "Rever", email: "renan.pac@paulicon.com.br" },
|
||||
{ name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br" },
|
||||
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
|
||||
];
|
||||
|
||||
const staffIds = await Promise.all(staffRoster.map((staff) => ensureUser(staff.name, staff.email)));
|
||||
const defaultAssigneeId = staffIds[0] ?? adminId;
|
||||
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
|
||||
const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER");
|
||||
|
||||
const templateDefinitions = [
|
||||
{
|
||||
title: "A Rever agradece seu contato",
|
||||
body: "<p>A Rever agradece seu contato. Recebemos sua solicitação e nossa equipe já está analisando os detalhes. Retornaremos com atualizações em breve.</p>",
|
||||
},
|
||||
{
|
||||
title: "Atualização do chamado",
|
||||
body: "<p>Seu chamado foi atualizado. Caso tenha novas informações ou dúvidas, basta responder a esta mensagem.</p>",
|
||||
},
|
||||
{
|
||||
title: "Chamado resolvido",
|
||||
body: "<p>Concluímos o atendimento deste chamado. A Rever agradece a parceria e permanecemos à disposição para novos suportes.</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const existingTemplates = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
for (const definition of templateDefinitions) {
|
||||
const already = existingTemplates.find((template) => template?.title === definition.title);
|
||||
if (already) continue;
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.insert("commentTemplates", {
|
||||
tenantId,
|
||||
title: definition.title,
|
||||
body: definition.body,
|
||||
createdBy: adminId,
|
||||
updatedBy: adminId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Seed a couple of tickets
|
||||
const now = Date.now();
|
||||
const newestRef = await ctx.db
|
||||
|
|
@ -90,7 +147,7 @@ export const seedDemo = mutation({
|
|||
channel: "EMAIL",
|
||||
queueId: queue1,
|
||||
requesterId: eduardaId,
|
||||
assigneeId: reverId,
|
||||
assigneeId: defaultAssigneeId,
|
||||
createdAt: now - 1000 * 60 * 60 * 5,
|
||||
updatedAt: now - 1000 * 60 * 10,
|
||||
tags: ["portal", "cliente"],
|
||||
|
|
@ -107,7 +164,7 @@ export const seedDemo = mutation({
|
|||
channel: "WHATSAPP",
|
||||
queueId: queue2,
|
||||
requesterId: clienteDemoId,
|
||||
assigneeId: agenteDemoId,
|
||||
assigneeId: defaultAssigneeId,
|
||||
createdAt: now - 1000 * 60 * 60 * 8,
|
||||
updatedAt: now - 1000 * 60 * 30,
|
||||
tags: ["Integração", "erp"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue