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
68
web/agents.md
Normal file
68
web/agents.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# 🧩 Permissões e acessos
|
||||||
|
|
||||||
|
- [ ] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
|
||||||
|
- [ ] Ver todos os chamados da sua empresa
|
||||||
|
- [ ] Acessar relatórios e dashboards resumidos
|
||||||
|
- [ ] Exportar relatórios em PDF ou CSV
|
||||||
|
- [ ] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧾 Tickets e atendimentos
|
||||||
|
|
||||||
|
- [ ] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações)
|
||||||
|
- [ ] Implementar **justificativa obrigatória ao pausar** o chamado
|
||||||
|
- [ ] Categorias: Falta de contato / Aguardando terceiro / Em procedimento
|
||||||
|
- [ ] Ajustar **status padrão dos tickets**
|
||||||
|
- [ ] Pendentes
|
||||||
|
- [ ] Aguardando atendimento
|
||||||
|
- [ ] Pausados
|
||||||
|
- [ ] (Remover “Aguardando resposta” e “Violados”)
|
||||||
|
- [ ] Remover automaticamente da listagem ao finalizar o chamado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📊 Dashboards e relatórios
|
||||||
|
|
||||||
|
- [ ] Criar **dashboard inicial com fila de atendimento**
|
||||||
|
- [ ] Exibir chamados em: atendimento, laboratório, visitas
|
||||||
|
- [ ] Indicadores: abertos, resolvidos, tempo médio, SLA
|
||||||
|
- [ ] Criar **relatório de horas por cliente (CSV/Dashboard)**
|
||||||
|
- [ ] Separar por atendimento interno e externo
|
||||||
|
- [ ] Filtrar por período (dia, semana, mês)
|
||||||
|
- [ ] Permitir exportar relatórios completos (CSV ou PDF)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⏱️ Controle de tempo e contratos
|
||||||
|
|
||||||
|
- [ ] Adicionar botão **Play interno** (atendimento remoto)
|
||||||
|
- [ ] Adicionar botão **Play externo** (atendimento presencial)
|
||||||
|
- [ ] Separar contagem de horas por tipo (interno/externo)
|
||||||
|
- [ ] Exibir e somar **horas gastas por cliente** (com base no tipo)
|
||||||
|
- [ ] Incluir no cadastro:
|
||||||
|
- [ ] Horas contratadas por mês
|
||||||
|
- [ ] Tipo de cliente: mensalista ou avulso
|
||||||
|
- [ ] Enviar alerta automático por e-mail quando atingir limite de horas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 💬 Comunicação e notificações
|
||||||
|
|
||||||
|
- [ ] Diferenciar **comentários públicos** e **privados**
|
||||||
|
- [ ] Público → envia e-mail ao cliente
|
||||||
|
- [ ] Privado → visível apenas internamente
|
||||||
|
- [ ] Enviar e-mail automático quando houver comentário público
|
||||||
|
- [ ] Incluir trecho da mensagem e link direto para o chamado
|
||||||
|
- [x] Criar **biblioteca de templates de comentário prontos**
|
||||||
|
- [x] Exemplo: “Agradecemos seu contato”, “Seu chamado foi atualizado”, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚙️ Extras e automações (futuro)
|
||||||
|
|
||||||
|
- [ ] Enviar alertas automáticos para gestores quando:
|
||||||
|
- [ ] Chamado estiver pausado há mais de 48h
|
||||||
|
- [ ] Horas contratadas atingirem 90% do limite
|
||||||
|
- [ ] Implementar **trilha de auditoria** (quem pausou, finalizou, comentou)
|
||||||
|
- [ ] Permitir exportar logs de auditoria (CSV/PDF)
|
||||||
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 bootstrap from "../bootstrap.js";
|
||||||
import type * as categories from "../categories.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 fields from "../fields.js";
|
||||||
import type * as files from "../files.js";
|
import type * as files from "../files.js";
|
||||||
import type * as invites from "../invites.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 queues from "../queues.js";
|
||||||
import type * as rbac from "../rbac.js";
|
import type * as rbac from "../rbac.js";
|
||||||
import type * as reports from "../reports.js";
|
import type * as reports from "../reports.js";
|
||||||
|
|
@ -39,9 +41,11 @@ import type {
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
bootstrap: typeof bootstrap;
|
bootstrap: typeof bootstrap;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
|
commentTemplates: typeof commentTemplates;
|
||||||
fields: typeof fields;
|
fields: typeof fields;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
invites: typeof invites;
|
invites: typeof invites;
|
||||||
|
migrations: typeof migrations;
|
||||||
queues: typeof queues;
|
queues: typeof queues;
|
||||||
rbac: typeof rbac;
|
rbac: typeof rbac;
|
||||||
reports: typeof reports;
|
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(),
|
createdAt: v.number(),
|
||||||
}).index("by_ticket", ["ticketId"]),
|
}).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({
|
ticketWorkSessions: defineTable({
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
agentId: v.id("users"),
|
agentId: v.id("users"),
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,71 @@ export const seedDemo = mutation({
|
||||||
.query("users")
|
.query("users")
|
||||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||||
.first();
|
.first();
|
||||||
if (found) return found._id;
|
if (found) {
|
||||||
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` });
|
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);
|
||||||
}
|
}
|
||||||
const reverId = await ensureUser("Rever", "renan.pac@paulicon.com.br");
|
return found._id;
|
||||||
const agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev");
|
}
|
||||||
|
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: role === "CUSTOMER" ? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}` });
|
||||||
|
}
|
||||||
|
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 eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
|
||||||
const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "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
|
// Seed a couple of tickets
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const newestRef = await ctx.db
|
const newestRef = await ctx.db
|
||||||
|
|
@ -90,7 +147,7 @@ export const seedDemo = mutation({
|
||||||
channel: "EMAIL",
|
channel: "EMAIL",
|
||||||
queueId: queue1,
|
queueId: queue1,
|
||||||
requesterId: eduardaId,
|
requesterId: eduardaId,
|
||||||
assigneeId: reverId,
|
assigneeId: defaultAssigneeId,
|
||||||
createdAt: now - 1000 * 60 * 60 * 5,
|
createdAt: now - 1000 * 60 * 60 * 5,
|
||||||
updatedAt: now - 1000 * 60 * 10,
|
updatedAt: now - 1000 * 60 * 10,
|
||||||
tags: ["portal", "cliente"],
|
tags: ["portal", "cliente"],
|
||||||
|
|
@ -107,7 +164,7 @@ export const seedDemo = mutation({
|
||||||
channel: "WHATSAPP",
|
channel: "WHATSAPP",
|
||||||
queueId: queue2,
|
queueId: queue2,
|
||||||
requesterId: clienteDemoId,
|
requesterId: clienteDemoId,
|
||||||
assigneeId: agenteDemoId,
|
assigneeId: defaultAssigneeId,
|
||||||
createdAt: now - 1000 * 60 * 60 * 8,
|
createdAt: now - 1000 * 60 * 60 * 8,
|
||||||
updatedAt: now - 1000 * 60 * 30,
|
updatedAt: now - 1000 * 60 * 30,
|
||||||
tags: ["Integração", "erp"],
|
tags: ["Integração", "erp"],
|
||||||
|
|
|
||||||
292
web/scripts/import-convex-to-prisma.mjs
Normal file
292
web/scripts/import-convex-to-prisma.mjs
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas"
|
||||||
|
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210"
|
||||||
|
const secret = process.env.CONVEX_SYNC_SECRET
|
||||||
|
const STAFF_ROSTER = [
|
||||||
|
{ email: "admin@sistema.dev", name: "Administrador", role: "ADMIN" },
|
||||||
|
{ email: "gabriel.oliveira@rever.com.br", name: "Gabriel Oliveira", role: "AGENT" },
|
||||||
|
{ email: "george.araujo@rever.com.br", name: "George Araujo", role: "AGENT" },
|
||||||
|
{ email: "hugo.soares@rever.com.br", name: "Hugo Soares", role: "AGENT" },
|
||||||
|
{ email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" },
|
||||||
|
{ email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" },
|
||||||
|
{ email: "renan.pac@paulicon.com.br", name: "Rever", role: "AGENT" },
|
||||||
|
{ email: "thiago.medeiros@rever.com.br", name: "Thiago Medeiros", role: "AGENT" },
|
||||||
|
{ email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rawDefaultAssigneeEmail = process.env.SYNC_DEFAULT_ASSIGNEE || "gabriel.oliveira@rever.com.br"
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedRoles = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
|
||||||
|
function normalizeEmail(email) {
|
||||||
|
if (!email) return null
|
||||||
|
return email.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAssigneeEmail = normalizeEmail(rawDefaultAssigneeEmail)
|
||||||
|
|
||||||
|
function toDate(value) {
|
||||||
|
if (!value && value !== 0) return null
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertUsers(snapshotUsers) {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
for (const user of snapshotUsers) {
|
||||||
|
const normalizedEmail = normalizeEmail(user.email)
|
||||||
|
if (!normalizedEmail) continue
|
||||||
|
|
||||||
|
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
|
||||||
|
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
|
||||||
|
|
||||||
|
const record = await prisma.user.upsert({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
update: {
|
||||||
|
name: user.name ?? normalizedEmail,
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: user.name ?? normalizedEmail,
|
||||||
|
role,
|
||||||
|
tenantId,
|
||||||
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.set(normalizedEmail, record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const staff of STAFF_ROSTER) {
|
||||||
|
const normalizedEmail = normalizeEmail(staff.email)
|
||||||
|
if (!normalizedEmail) continue
|
||||||
|
const record = await prisma.user.upsert({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
update: {
|
||||||
|
name: staff.name,
|
||||||
|
role: staff.role,
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: staff.name,
|
||||||
|
role: staff.role,
|
||||||
|
tenantId,
|
||||||
|
avatarUrl: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.set(normalizedEmail, record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedStaffEmails = new Set(STAFF_ROSTER.map((staff) => normalizeEmail(staff.email)).filter(Boolean))
|
||||||
|
|
||||||
|
const removableStaff = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
role: { in: ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] },
|
||||||
|
email: {
|
||||||
|
notIn: Array.from(allowedStaffEmails),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackAssigneeId = defaultAssigneeEmail ? map.get(defaultAssigneeEmail) ?? null : null
|
||||||
|
|
||||||
|
for (const staff of removableStaff) {
|
||||||
|
if (fallbackAssigneeId) {
|
||||||
|
await prisma.ticket.updateMany({
|
||||||
|
where: { tenantId, assigneeId: staff.id },
|
||||||
|
data: { assigneeId: fallbackAssigneeId },
|
||||||
|
})
|
||||||
|
await prisma.ticketComment.updateMany({
|
||||||
|
where: { authorId: staff.id },
|
||||||
|
data: { authorId: fallbackAssigneeId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: staff.id },
|
||||||
|
data: {
|
||||||
|
role: "CUSTOMER",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertQueues(snapshotQueues) {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
for (const queue of snapshotQueues) {
|
||||||
|
if (!queue.slug) continue
|
||||||
|
const record = await prisma.queue.upsert({
|
||||||
|
where: {
|
||||||
|
tenantId_slug: {
|
||||||
|
tenantId,
|
||||||
|
slug: queue.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: queue.name,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
name: queue.name,
|
||||||
|
slug: queue.slug,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.set(queue.slug, record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTickets(snapshotTickets, userMap, queueMap) {
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
|
||||||
|
const fallbackAssigneeId = defaultAssigneeEmail ? userMap.get(defaultAssigneeEmail) ?? null : null
|
||||||
|
|
||||||
|
for (const ticket of snapshotTickets) {
|
||||||
|
if (!ticket.requesterEmail) continue
|
||||||
|
|
||||||
|
const requesterId = userMap.get(normalizeEmail(ticket.requesterEmail))
|
||||||
|
if (!requesterId) continue
|
||||||
|
|
||||||
|
const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null
|
||||||
|
|
||||||
|
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
|
||||||
|
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
|
||||||
|
|
||||||
|
const existing = await prisma.ticket.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
reference: ticket.reference,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
subject: ticket.subject,
|
||||||
|
summary: ticket.summary ?? null,
|
||||||
|
status: (ticket.status ?? "NEW").toUpperCase(),
|
||||||
|
priority: (ticket.priority ?? "MEDIUM").toUpperCase(),
|
||||||
|
channel: (ticket.channel ?? "MANUAL").toUpperCase(),
|
||||||
|
queueId,
|
||||||
|
requesterId,
|
||||||
|
assigneeId,
|
||||||
|
dueAt: toDate(ticket.dueAt),
|
||||||
|
firstResponseAt: toDate(ticket.firstResponseAt),
|
||||||
|
resolvedAt: toDate(ticket.resolvedAt),
|
||||||
|
closedAt: toDate(ticket.closedAt),
|
||||||
|
createdAt: toDate(ticket.createdAt) ?? new Date(),
|
||||||
|
updatedAt: toDate(ticket.updatedAt) ?? new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticketRecord
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
ticketRecord = await prisma.ticket.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
updated += 1
|
||||||
|
} else {
|
||||||
|
ticketRecord = await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
reference: ticket.reference,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ticketComment.deleteMany({ where: { ticketId: ticketRecord.id } })
|
||||||
|
await prisma.ticketEvent.deleteMany({ where: { ticketId: ticketRecord.id } })
|
||||||
|
|
||||||
|
const commentsData = ticket.comments
|
||||||
|
.map((comment) => {
|
||||||
|
const authorId = comment.authorEmail ? userMap.get(normalizeEmail(comment.authorEmail)) : null
|
||||||
|
if (!authorId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ticketId: ticketRecord.id,
|
||||||
|
authorId,
|
||||||
|
visibility: (comment.visibility ?? "INTERNAL").toUpperCase(),
|
||||||
|
body: comment.body ?? "",
|
||||||
|
attachments: null,
|
||||||
|
createdAt: toDate(comment.createdAt) ?? new Date(),
|
||||||
|
updatedAt: toDate(comment.updatedAt) ?? new Date(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (commentsData.length > 0) {
|
||||||
|
await prisma.ticketComment.createMany({ data: commentsData })
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsData = ticket.events.map((event) => ({
|
||||||
|
ticketId: ticketRecord.id,
|
||||||
|
type: event.type ?? "UNKNOWN",
|
||||||
|
payload: event.payload ?? {},
|
||||||
|
createdAt: toDate(event.createdAt) ?? new Date(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (eventsData.length > 0) {
|
||||||
|
await prisma.ticketEvent.createMany({ data: eventsData })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("Baixando snapshot do Convex...")
|
||||||
|
const snapshot = await client.query("migrations:exportTenantSnapshot", {
|
||||||
|
secret,
|
||||||
|
tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Usuários recebidos: ${snapshot.users.length}`)
|
||||||
|
console.log(`Filas recebidas: ${snapshot.queues.length}`)
|
||||||
|
console.log(`Tickets recebidos: ${snapshot.tickets.length}`)
|
||||||
|
|
||||||
|
console.log("Sincronizando usuários no Prisma...")
|
||||||
|
const userMap = await upsertUsers(snapshot.users)
|
||||||
|
console.log(`Usuários ativos no mapa: ${userMap.size}`)
|
||||||
|
|
||||||
|
console.log("Sincronizando filas no Prisma...")
|
||||||
|
const queueMap = await upsertQueues(snapshot.queues)
|
||||||
|
console.log(`Filas ativas no mapa: ${queueMap.size}`)
|
||||||
|
|
||||||
|
console.log("Sincronizando tickets no Prisma...")
|
||||||
|
const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap)
|
||||||
|
console.log(`Tickets criados: ${ticketStats.created}`)
|
||||||
|
console.log(`Tickets atualizados: ${ticketStats.updated}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Falha ao importar dados do Convex para Prisma", error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
|
@ -24,13 +24,6 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
role: "admin",
|
role: "admin",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
email: "agente.demo@sistema.dev",
|
|
||||||
password: "agent123",
|
|
||||||
name: "Agente Demo",
|
|
||||||
role: "agent",
|
|
||||||
tenantId,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
email: "cliente.demo@sistema.dev",
|
email: "cliente.demo@sistema.dev",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
|
|
@ -38,6 +31,62 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
role: "customer",
|
role: "customer",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
email: "gabriel.oliveira@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Gabriel Oliveira",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "george.araujo@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "George Araujo",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "hugo.soares@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Hugo Soares",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "julio@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Julio Cesar",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "lorena@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Lorena Magalhães",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "renan.pac@paulicon.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Rever",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "thiago.medeiros@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Thiago Medeiros",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "weslei@rever.com.br",
|
||||||
|
password: "agent123",
|
||||||
|
name: "Weslei Magalhães",
|
||||||
|
role: "agent",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function upsertAuthUser({ email, password, name, role, tenantId: userTenant }: (typeof defaultUsers)[number]) {
|
async function upsertAuthUser({ email, password, name, role, tenantId: userTenant }: (typeof defaultUsers)[number]) {
|
||||||
|
|
|
||||||
136
web/scripts/sync-prisma-to-convex.mjs
Normal file
136
web/scripts/sync-prisma-to-convex.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
function toMillis(date) {
|
||||||
|
return date instanceof Date ? date.getTime() : date ? new Date(date).getTime() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value, fallback = "") {
|
||||||
|
if (!value) return fallback
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return normalizeString(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-") || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas"
|
||||||
|
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210"
|
||||||
|
const secret = process.env.CONVEX_SYNC_SECRET
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, queues, tickets] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
include: { team: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.queue.findMany(),
|
||||||
|
prisma.ticket.findMany({
|
||||||
|
include: {
|
||||||
|
requester: true,
|
||||||
|
assignee: true,
|
||||||
|
queue: true,
|
||||||
|
comments: {
|
||||||
|
include: {
|
||||||
|
author: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const userSnapshot = users.map((user) => ({
|
||||||
|
email: user.email,
|
||||||
|
name: normalizeString(user.name, user.email),
|
||||||
|
role: user.role,
|
||||||
|
avatarUrl: user.avatarUrl ?? undefined,
|
||||||
|
teams: user.teams
|
||||||
|
.map((membership) => membership.team?.name)
|
||||||
|
.filter((name) => Boolean(name) && typeof name === "string"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const queueSnapshot = queues.map((queue) => ({
|
||||||
|
name: normalizeString(queue.name, queue.slug ?? queue.id),
|
||||||
|
slug: queue.slug ? queue.slug : normalizeString(queue.name, queue.id).toLowerCase().replace(/\s+/g, "-"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const referenceFallbackStart = 41000
|
||||||
|
let referenceCounter = referenceFallbackStart
|
||||||
|
|
||||||
|
const ticketSnapshot = tickets.map((ticket) => {
|
||||||
|
const reference = ticket.reference && ticket.reference > 0 ? ticket.reference : ++referenceCounter
|
||||||
|
const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com"
|
||||||
|
const assigneeEmail = ticket.assignee?.email ?? undefined
|
||||||
|
const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
reference,
|
||||||
|
subject: normalizeString(ticket.subject, `Ticket ${reference}`),
|
||||||
|
summary: ticket.summary ?? undefined,
|
||||||
|
status: ticket.status,
|
||||||
|
priority: ticket.priority,
|
||||||
|
channel: ticket.channel,
|
||||||
|
queueSlug: queueSlug ?? undefined,
|
||||||
|
requesterEmail,
|
||||||
|
assigneeEmail,
|
||||||
|
dueAt: toMillis(ticket.dueAt) ?? undefined,
|
||||||
|
firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined,
|
||||||
|
resolvedAt: toMillis(ticket.resolvedAt) ?? undefined,
|
||||||
|
closedAt: toMillis(ticket.closedAt) ?? undefined,
|
||||||
|
createdAt: toMillis(ticket.createdAt) ?? Date.now(),
|
||||||
|
updatedAt: toMillis(ticket.updatedAt) ?? Date.now(),
|
||||||
|
tags: Array.isArray(ticket.tags) ? ticket.tags : undefined,
|
||||||
|
comments: ticket.comments.map((comment) => ({
|
||||||
|
authorEmail: comment.author?.email ?? requesterEmail,
|
||||||
|
visibility: comment.visibility,
|
||||||
|
body: comment.body,
|
||||||
|
createdAt: toMillis(comment.createdAt) ?? Date.now(),
|
||||||
|
updatedAt: toMillis(comment.updatedAt) ?? Date.now(),
|
||||||
|
})),
|
||||||
|
events: ticket.events.map((event) => ({
|
||||||
|
type: event.type,
|
||||||
|
payload: event.payload ?? {},
|
||||||
|
createdAt: toMillis(event.createdAt) ?? Date.now(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
|
||||||
|
const result = await client.mutation("migrations:importPrismaSnapshot", {
|
||||||
|
secret,
|
||||||
|
snapshot: {
|
||||||
|
tenantId,
|
||||||
|
users: userSnapshot,
|
||||||
|
queues: queueSnapshot,
|
||||||
|
tickets: ticketSnapshot,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Sincronização concluída:", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
24
web/src/app/settings/templates/page.tsx
Normal file
24
web/src/app/settings/templates/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { CommentTemplatesManager } from "@/components/settings/comment-templates-manager"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { requireStaffSession } from "@/lib/auth-server"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
export default async function CommentTemplatesPage() {
|
||||||
|
await requireStaffSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
title="Templates de comentário"
|
||||||
|
lead="Mantenha respostas prontas e alinhadas com a voz da Rever."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CommentTemplatesManager />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
322
web/src/components/settings/comment-templates-manager.tsx
Normal file
322
web/src/components/settings/comment-templates-manager.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
|
||||||
|
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { sanitizeEditorHtml, RichTextEditor, RichTextContent } from "@/components/ui/rich-text-editor"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
||||||
|
export function CommentTemplatesManager() {
|
||||||
|
const { convexUserId, session } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const viewerId = convexUserId as Id<"users"> | undefined
|
||||||
|
|
||||||
|
const templates = useQuery(
|
||||||
|
viewerId ? api.commentTemplates.list : "skip",
|
||||||
|
viewerId ? { tenantId, viewerId } : "skip"
|
||||||
|
) as
|
||||||
|
| {
|
||||||
|
id: Id<"commentTemplates">
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
createdBy: Id<"users">
|
||||||
|
updatedBy: Id<"users"> | null
|
||||||
|
}[]
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
const createTemplate = useMutation(api.commentTemplates.create)
|
||||||
|
const updateTemplate = useMutation(api.commentTemplates.update)
|
||||||
|
const deleteTemplate = useMutation(api.commentTemplates.remove)
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [body, setBody] = useState("")
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const isLoading = viewerId && templates === undefined
|
||||||
|
|
||||||
|
const orderedTemplates = useMemo(() => templates ?? [], [templates])
|
||||||
|
|
||||||
|
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!viewerId) return
|
||||||
|
const trimmedTitle = title.trim()
|
||||||
|
const sanitizedBody = sanitizeEditorHtml(body)
|
||||||
|
if (trimmedTitle.length < 3) {
|
||||||
|
toast.error("Informe um título com pelo menos 3 caracteres.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!sanitizedBody) {
|
||||||
|
toast.error("Escreva o conteúdo do template antes de salvar.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsSubmitting(true)
|
||||||
|
toast.loading("Criando template...", { id: "create-template" })
|
||||||
|
try {
|
||||||
|
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody })
|
||||||
|
toast.success("Template criado!", { id: "create-template" })
|
||||||
|
setTitle("")
|
||||||
|
setBody("")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível criar o template.", { id: "create-template" })
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string) {
|
||||||
|
if (!viewerId) return
|
||||||
|
const trimmedTitle = nextTitle.trim()
|
||||||
|
const sanitizedBody = sanitizeEditorHtml(nextBody)
|
||||||
|
if (trimmedTitle.length < 3) {
|
||||||
|
toast.error("Informe um título com pelo menos 3 caracteres.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!sanitizedBody) {
|
||||||
|
toast.error("Escreva o conteúdo do template antes de salvar.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const toastId = `update-template-${templateId}`
|
||||||
|
toast.loading("Atualizando template...", { id: toastId })
|
||||||
|
try {
|
||||||
|
await updateTemplate({
|
||||||
|
templateId,
|
||||||
|
tenantId,
|
||||||
|
actorId: viewerId,
|
||||||
|
title: trimmedTitle,
|
||||||
|
body: sanitizedBody,
|
||||||
|
})
|
||||||
|
toast.success("Template atualizado!", { id: toastId })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível atualizar o template.", { id: toastId })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(templateId: Id<"commentTemplates">) {
|
||||||
|
if (!viewerId) return
|
||||||
|
const toastId = `delete-template-${templateId}`
|
||||||
|
toast.loading("Removendo template...", { id: toastId })
|
||||||
|
try {
|
||||||
|
await deleteTemplate({ templateId, tenantId, actorId: viewerId })
|
||||||
|
toast.success("Template removido!", { id: toastId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível remover o template.", { id: toastId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewerId) {
|
||||||
|
return (
|
||||||
|
<Card className="border border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Templates de comentário</CardTitle>
|
||||||
|
<CardDescription>Faça login para gerenciar os templates de resposta rápida.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border border-slate-200">
|
||||||
|
<CardHeader className="flex flex-col gap-1">
|
||||||
|
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-neutral-600">
|
||||||
|
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4" onSubmit={handleCreate}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
|
||||||
|
Título do template
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="template-title"
|
||||||
|
placeholder="Ex.: A Rever agradece seu contato"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
|
||||||
|
Conteúdo padrão
|
||||||
|
</label>
|
||||||
|
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder="Escreva a mensagem padrão..." />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{body ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setBody("")
|
||||||
|
setTitle("")
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<IconX className="size-4" />
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="inline-flex items-center gap-2" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? <Spinner className="size-4 text-white" /> : <IconPlus className="size-4" />}Salvar template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-slate-200">
|
||||||
|
<CardHeader className="flex flex-col gap-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||||
|
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-neutral-600">
|
||||||
|
Gerencie as mensagens prontas utilizadas nos comentários de tickets.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
|
<Spinner className="size-4" /> Carregando templates...
|
||||||
|
</div>
|
||||||
|
) : orderedTemplates.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<IconFileText className="size-5 text-neutral-500" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Nenhum template cadastrado</EmptyTitle>
|
||||||
|
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{orderedTemplates.map((template) => (
|
||||||
|
<TemplateItem
|
||||||
|
key={template.id}
|
||||||
|
template={template}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateItemProps = {
|
||||||
|
template: {
|
||||||
|
id: Id<"commentTemplates">
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise<boolean | void>
|
||||||
|
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [title, setTitle] = useState(template.title)
|
||||||
|
const [body, setBody] = useState(template.body)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const lastUpdated = useMemo(() => new Date(template.updatedAt), [template.updatedAt])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setIsSaving(true)
|
||||||
|
const ok = await onSave(template.id, title, body)
|
||||||
|
setIsSaving(false)
|
||||||
|
if (ok !== false) {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setIsDeleting(true)
|
||||||
|
await onDelete(template.id)
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Título" />
|
||||||
|
<RichTextEditor value={body} onChange={setBody} minHeight={160} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900">{template.title}</h3>
|
||||||
|
<RichTextContent html={template.body} className="text-sm text-neutral-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-neutral-500">
|
||||||
|
<span>Atualizado em {lastUpdated.toLocaleString("pt-BR")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
setTitle(template.title)
|
||||||
|
setBody(template.body)
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? <Spinner className="size-4 text-white" /> : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Spinner className="size-4 text-white" /> : <IconTrash className="size-4" />}Excluir
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3 } from "lucide-react"
|
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -67,6 +67,14 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
icon: UserPlus,
|
icon: UserPlus,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Templates de comentários",
|
||||||
|
description: "Gerencie mensagens rápidas utilizadas nos atendimentos.",
|
||||||
|
href: "/settings/templates",
|
||||||
|
cta: "Abrir templates",
|
||||||
|
requiredRole: "staff",
|
||||||
|
icon: MessageSquareText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Preferências da equipe",
|
title: "Preferências da equipe",
|
||||||
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",
|
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
||||||
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||||
import { useAction, useMutation } from "convex/react"
|
import { useAction, useMutation, useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
@ -19,6 +19,7 @@ import { toast } from "sonner"
|
||||||
import { Dropzone } from "@/components/ui/dropzone"
|
import { Dropzone } from "@/components/ui/dropzone"
|
||||||
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
@ -33,7 +34,7 @@ const submitButtonClass =
|
||||||
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
|
||||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId, isStaff } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||||
const updateComment = useMutation(api.tickets.updateComment)
|
const updateComment = useMutation(api.tickets.updateComment)
|
||||||
|
|
@ -48,6 +49,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
|
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
|
||||||
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const templateArgs = convexUserId && isStaff
|
||||||
|
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
|
||||||
|
: "skip"
|
||||||
|
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
|
||||||
|
| { id: string; title: string; body: string }[]
|
||||||
|
| undefined
|
||||||
|
const templates = templatesResult ?? []
|
||||||
|
const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined
|
||||||
|
const canUseTemplates = Boolean(convexUserId && isStaff)
|
||||||
|
|
||||||
|
const insertTemplateIntoBody = (html: string) => {
|
||||||
|
const sanitized = sanitizeEditorHtml(html)
|
||||||
|
setBody((current) => {
|
||||||
|
if (!current) return sanitized
|
||||||
|
const merged = `${current}<p><br /></p>${sanitized}`
|
||||||
|
return sanitizeEditorHtml(merged)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
|
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
|
||||||
setEditingComment({ id: commentId, value: currentBody || "" })
|
setEditingComment({ id: commentId, value: currentBody || "" })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -352,8 +372,47 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
|
||||||
|
{canUseTemplates ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
|
||||||
|
disabled={templatesLoading}
|
||||||
|
>
|
||||||
|
<IconFileText className="size-4" />
|
||||||
|
Inserir template
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
|
{templatesLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
Carregando templates...
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-neutral-500">
|
||||||
|
Nenhum template disponível. Cadastre novos em configurações.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
templates.map((template) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={template.id}
|
||||||
|
className="flex flex-col items-start whitespace-normal py-2"
|
||||||
|
onSelect={() => insertTemplateIntoBody(template.body)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-neutral-800">{template.title}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
Visibilidade:
|
Visibilidade:
|
||||||
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
|
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
|
||||||
<SelectTrigger className={selectTriggerClass}>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
|
|
@ -365,6 +424,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button type="submit" size="sm" className={submitButtonClass}>
|
<Button type="submit" size="sm" className={submitButtonClass}>
|
||||||
Enviar
|
Enviar
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue