feat: add company management and manager role support

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-06 21:26:43 -03:00
parent 409cbea7b9
commit 854887f499
16 changed files with 955 additions and 126 deletions

View file

@ -1,10 +1,10 @@
# 🧩 Permissões e acessos # 🧩 Permissões e acessos
- [ ] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas - [x] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
- [ ] Ver todos os chamados da sua empresa - [x] Ver todos os chamados da sua empresa
- [ ] Acessar relatórios e dashboards resumidos - [x] Acessar relatórios e dashboards resumidos
- [ ] Exportar relatórios em PDF ou CSV - [ ] Exportar relatórios em PDF ou CSV
- [ ] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final - [x] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
--- ---

View file

@ -6,7 +6,8 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret" const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
function normalizeEmail(value: string) { function normalizeEmail(value: string) {
return value.trim().toLowerCase() return value.trim().toLowerCase()
@ -18,6 +19,7 @@ type ImportedUser = {
role?: string | null role?: string | null
avatarUrl?: string | null avatarUrl?: string | null
teams?: string[] | null teams?: string[] | null
companySlug?: string | null
} }
type ImportedQueue = { type ImportedQueue = {
@ -25,11 +27,22 @@ type ImportedQueue = {
name: string name: string
} }
type ImportedCompany = {
slug: string
name: string
cnpj?: string | null
domain?: string | null
phone?: string | null
description?: string | null
address?: string | null
createdAt?: number | null
updatedAt?: number | null
}
function normalizeRole(role: string | null | undefined) { function normalizeRole(role: string | null | undefined) {
if (!role) return "AGENT" if (!role) return "AGENT"
const normalized = role.toUpperCase() const normalized = role.toUpperCase()
if (STAFF_ROLES.has(normalized)) return normalized if (VALID_ROLES.has(normalized)) return normalized
if (normalized === "CUSTOMER") return "CUSTOMER"
return "AGENT" return "AGENT"
} }
@ -57,7 +70,8 @@ async function ensureUser(
ctx: MutationCtx, ctx: MutationCtx,
tenantId: string, tenantId: string,
data: ImportedUser, data: ImportedUser,
cache: Map<string, Id<"users">> cache: Map<string, Id<"users">>,
companyCache: Map<string, Id<"companies">>
) { ) {
if (cache.has(data.email)) { if (cache.has(data.email)) {
return cache.get(data.email)! return cache.get(data.email)!
@ -68,13 +82,15 @@ async function ensureUser(
.first() .first()
const role = normalizeRole(data.role) const role = normalizeRole(data.role)
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
const record = existing const record = existing
? (() => { ? (() => {
const needsPatch = const needsPatch =
existing.name !== data.name || existing.name !== data.name ||
existing.role !== role || existing.role !== role ||
existing.avatarUrl !== (data.avatarUrl ?? undefined) || existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
(existing.companyId ?? undefined) !== companyId
if (needsPatch) { if (needsPatch) {
return ctx.db.patch(existing._id, { return ctx.db.patch(existing._id, {
name: data.name, name: data.name,
@ -82,6 +98,7 @@ async function ensureUser(
avatarUrl: data.avatarUrl ?? undefined, avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined, teams: data.teams ?? undefined,
tenantId, tenantId,
companyId,
}) })
} }
return Promise.resolve() return Promise.resolve()
@ -93,6 +110,7 @@ async function ensureUser(
role, role,
avatarUrl: data.avatarUrl ?? undefined, avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined, teams: data.teams ?? undefined,
companyId,
}) })
const id = existing ? existing._id : ((await record) as Id<"users">) const id = existing ? existing._id : ((await record) as Id<"users">)
@ -144,6 +162,64 @@ async function ensureQueue(
return id return id
} }
async function ensureCompany(
ctx: MutationCtx,
tenantId: string,
data: ImportedCompany,
cache: Map<string, Id<"companies">>
) {
const slug = data.slug || slugify(data.name)
if (cache.has(slug)) {
return cache.get(slug)!
}
const existing = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
const payload = pruneUndefined({
tenantId,
name: data.name,
slug,
cnpj: data.cnpj ?? undefined,
domain: data.domain ?? undefined,
phone: data.phone ?? undefined,
description: data.description ?? undefined,
address: data.address ?? undefined,
createdAt: data.createdAt ?? Date.now(),
updatedAt: data.updatedAt ?? Date.now(),
})
let id: Id<"companies">
if (existing) {
const needsPatch =
existing.name !== payload.name ||
existing.cnpj !== (payload.cnpj ?? undefined) ||
existing.domain !== (payload.domain ?? undefined) ||
existing.phone !== (payload.phone ?? undefined) ||
existing.description !== (payload.description ?? undefined) ||
existing.address !== (payload.address ?? undefined)
if (needsPatch) {
await ctx.db.patch(existing._id, {
name: payload.name,
cnpj: payload.cnpj,
domain: payload.domain,
phone: payload.phone,
description: payload.description,
address: payload.address,
updatedAt: Date.now(),
})
}
id = existing._id
} else {
id = await ctx.db.insert("companies", payload)
}
cache.set(slug, id)
return id
}
async function getTenantUsers(ctx: QueryCtx, tenantId: string) { async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
return ctx.db return ctx.db
.query("users") .query("users")
@ -158,6 +234,13 @@ async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
.collect() .collect()
} }
async function getTenantCompanies(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
export const exportTenantSnapshot = query({ export const exportTenantSnapshot = query({
args: { args: {
secret: v.string(), secret: v.string(),
@ -168,10 +251,15 @@ export const exportTenantSnapshot = query({
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend") throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
} }
const [users, queues] = await Promise.all([getTenantUsers(ctx, tenantId), getTenantQueues(ctx, tenantId)]) const [users, queues, companies] = await Promise.all([
getTenantUsers(ctx, tenantId),
getTenantQueues(ctx, tenantId),
getTenantCompanies(ctx, tenantId),
])
const userMap = new Map(users.map((user) => [user._id, user])) const userMap = new Map(users.map((user) => [user._id, user]))
const queueMap = new Map(queues.map((queue) => [queue._id, queue])) const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
const companyMap = new Map(companies.map((company) => [company._id, company]))
const tickets = await ctx.db const tickets = await ctx.db
.query("tickets") .query("tickets")
@ -194,6 +282,11 @@ export const exportTenantSnapshot = query({
const requester = userMap.get(ticket.requesterId) const requester = userMap.get(ticket.requesterId)
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
const company = ticket.companyId
? companyMap.get(ticket.companyId)
: requester?.companyId
? companyMap.get(requester.companyId)
: undefined
if (!requester) { if (!requester) {
continue continue
@ -209,6 +302,7 @@ export const exportTenantSnapshot = query({
queueSlug: queue?.slug ?? undefined, queueSlug: queue?.slug ?? undefined,
requesterEmail: requester.email, requesterEmail: requester.email,
assigneeEmail: assignee?.email ?? undefined, assigneeEmail: assignee?.email ?? undefined,
companySlug: company?.slug ?? undefined,
dueAt: ticket.dueAt ?? undefined, dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined, firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined, resolvedAt: ticket.resolvedAt ?? undefined,
@ -247,12 +341,24 @@ export const exportTenantSnapshot = query({
return { return {
tenantId, tenantId,
companies: companies.map((company) => ({
slug: company.slug,
name: company.name,
cnpj: company.cnpj ?? null,
domain: company.domain ?? null,
phone: company.phone ?? null,
description: company.description ?? null,
address: company.address ?? null,
createdAt: company.createdAt,
updatedAt: company.updatedAt,
})),
users: users.map((user) => ({ users: users.map((user) => ({
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role ?? null, role: user.role ?? null,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
teams: user.teams ?? [], teams: user.teams ?? [],
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
})), })),
queues: queues.map((queue) => ({ queues: queues.map((queue) => ({
name: queue.name, name: queue.name,
@ -268,6 +374,19 @@ export const importPrismaSnapshot = mutation({
secret: v.string(), secret: v.string(),
snapshot: v.object({ snapshot: v.object({
tenantId: v.string(), tenantId: v.string(),
companies: v.array(
v.object({
slug: v.string(),
name: v.string(),
cnpj: v.optional(v.string()),
domain: v.optional(v.string()),
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
),
users: v.array( users: v.array(
v.object({ v.object({
email: v.string(), email: v.string(),
@ -275,6 +394,7 @@ export const importPrismaSnapshot = mutation({
role: v.optional(v.string()), role: v.optional(v.string()),
avatarUrl: v.optional(v.string()), avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())), teams: v.optional(v.array(v.string())),
companySlug: v.optional(v.string()),
}) })
), ),
queues: v.array( queues: v.array(
@ -294,6 +414,7 @@ export const importPrismaSnapshot = mutation({
queueSlug: v.optional(v.string()), queueSlug: v.optional(v.string()),
requesterEmail: v.string(), requesterEmail: v.string(),
assigneeEmail: v.optional(v.string()), assigneeEmail: v.optional(v.string()),
companySlug: v.optional(v.string()),
dueAt: v.optional(v.number()), dueAt: v.optional(v.number()),
firstResponseAt: v.optional(v.number()), firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()), resolvedAt: v.optional(v.number()),
@ -329,11 +450,16 @@ export const importPrismaSnapshot = mutation({
throw new ConvexError("Segredo inválido para sincronização") throw new ConvexError("Segredo inválido para sincronização")
} }
const companyCache = new Map<string, Id<"companies">>()
const userCache = new Map<string, Id<"users">>() const userCache = new Map<string, Id<"users">>()
const queueCache = new Map<string, Id<"queues">>() const queueCache = new Map<string, Id<"queues">>()
for (const company of snapshot.companies) {
await ensureCompany(ctx, snapshot.tenantId, company, companyCache)
}
for (const user of snapshot.users) { for (const user of snapshot.users) {
await ensureUser(ctx, snapshot.tenantId, user, userCache) await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
} }
for (const queue of snapshot.queues) { for (const queue of snapshot.queues) {
@ -342,7 +468,7 @@ export const importPrismaSnapshot = mutation({
const snapshotStaffEmails = new Set( const snapshotStaffEmails = new Set(
snapshot.users snapshot.users
.filter((user) => normalizeRole(user.role ?? null) !== "CUSTOMER") .filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
.map((user) => normalizeEmail(user.email)) .map((user) => normalizeEmail(user.email))
) )
@ -353,7 +479,7 @@ export const importPrismaSnapshot = mutation({
for (const user of existingTenantUsers) { for (const user of existingTenantUsers) {
const role = normalizeRole(user.role ?? null) const role = normalizeRole(user.role ?? null)
if (STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) { if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
await ctx.db.delete(user._id) await ctx.db.delete(user._id)
} }
} }
@ -370,7 +496,8 @@ export const importPrismaSnapshot = mutation({
email: ticket.requesterEmail, email: ticket.requesterEmail,
name: ticket.requesterEmail, name: ticket.requesterEmail,
}, },
userCache userCache,
companyCache
) )
const assigneeId = ticket.assigneeEmail const assigneeId = ticket.assigneeEmail
? await ensureUser( ? await ensureUser(
@ -380,11 +507,13 @@ export const importPrismaSnapshot = mutation({
email: ticket.assigneeEmail, email: ticket.assigneeEmail,
name: ticket.assigneeEmail, name: ticket.assigneeEmail,
}, },
userCache userCache,
companyCache
) )
: undefined : undefined
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined
const existing = await ctx.db const existing = await ctx.db
.query("tickets") .query("tickets")
@ -406,6 +535,7 @@ export const importPrismaSnapshot = mutation({
assigneeId: assigneeId as Id<"users"> | undefined, assigneeId: assigneeId as Id<"users"> | undefined,
working: false, working: false,
slaPolicyId: undefined, slaPolicyId: undefined,
companyId: companyId as Id<"companies"> | undefined,
dueAt: ticket.dueAt ?? undefined, dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined, firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined, resolvedAt: ticket.resolvedAt ?? undefined,
@ -452,7 +582,8 @@ export const importPrismaSnapshot = mutation({
email: comment.authorEmail, email: comment.authorEmail,
name: comment.authorEmail, name: comment.authorEmail,
}, },
userCache userCache,
companyCache
) )
await ctx.db.insert("ticketComments", { await ctx.db.insert("ticketComments", {
ticketId, ticketId,

View file

@ -5,6 +5,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const CUSTOMER_ROLE = "CUSTOMER" const CUSTOMER_ROLE = "CUSTOMER"
const MANAGER_ROLE = "MANAGER"
type Ctx = QueryCtx | MutationCtx type Ctx = QueryCtx | MutationCtx
@ -51,3 +52,30 @@ export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?:
} }
return result return result
} }
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (result.role !== MANAGER_ROLE) {
throw new ConvexError("Apenas gestores da empresa podem executar esta ação")
}
if (!result.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
return result
}
export async function requireCompanyAssociation(
ctx: Ctx,
userId: Id<"users">,
companyId: Id<"companies">,
tenantId?: string,
) {
const result = await requireUser(ctx, userId, tenantId)
if (!result.user.companyId) {
throw new ConvexError("Usuário não possui empresa vinculada")
}
if (result.user.companyId !== companyId) {
throw new ConvexError("Usuário não pertence a esta empresa")
}
return result
}

View file

@ -1,6 +1,6 @@
import { query } from "./_generated/server"; import { query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server"; import type { QueryCtx } from "./_generated/server";
import { v } from "convex/values"; import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac"; import { requireStaff } from "./rbac";
@ -42,6 +42,25 @@ async function fetchTickets(ctx: QueryCtx, tenantId: string) {
.collect(); .collect();
} }
async function fetchScopedTickets(
ctx: QueryCtx,
tenantId: string,
viewer: Awaited<ReturnType<typeof requireStaff>>,
) {
if (viewer.role === "MANAGER") {
if (!viewer.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!)
)
.collect();
}
return fetchTickets(ctx, tenantId);
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) { async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db return ctx.db
.query("queues") .query("queues")
@ -92,8 +111,8 @@ function formatDateKey(timestamp: number) {
export const slaOverview = query({ export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const queues = await fetchQueues(ctx, tenantId); const queues = await fetchQueues(ctx, tenantId);
const now = Date.now(); const now = Date.now();
@ -140,8 +159,8 @@ export const slaOverview = query({
export const csatOverview = query({ export const csatOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const surveys = await collectCsatSurveys(ctx, tickets); const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score)); const averageScore = average(surveys.map((item) => item.score));
@ -171,8 +190,8 @@ export const csatOverview = query({
export const backlogOverview = query({ export const backlogOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => { const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1; acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
@ -218,8 +237,8 @@ export const backlogOverview = query({
export const dashboardOverview = query({ export const dashboardOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const now = Date.now(); const now = Date.now();
const lastDayStart = now - ONE_DAY_MS; const lastDayStart = now - ONE_DAY_MS;
@ -294,8 +313,8 @@ export const ticketsByChannel = query({
range: v.optional(v.string()), range: v.optional(v.string()),
}, },
handler: async (ctx, { tenantId, viewerId, range }) => { handler: async (ctx, { tenantId, viewerId, range }) => {
await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date(); const end = new Date();

View file

@ -9,9 +9,26 @@ export default defineSchema({
role: v.optional(v.string()), role: v.optional(v.string()),
avatarUrl: v.optional(v.string()), avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())), teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
}) })
.index("by_tenant_email", ["tenantId", "email"]) .index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"]) .index("by_tenant_role", ["tenantId", "role"])
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"]),
companies: defineTable({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
cnpj: v.optional(v.string()),
domain: v.optional(v.string()),
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]), .index("by_tenant", ["tenantId"]),
queues: defineTable({ queues: defineTable({
@ -51,6 +68,7 @@ export default defineSchema({
subcategoryId: v.optional(v.id("ticketSubcategories")), subcategoryId: v.optional(v.id("ticketSubcategories")),
requesterId: v.id("users"), requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")), assigneeId: v.optional(v.id("users")),
companyId: v.optional(v.id("companies")),
working: v.optional(v.boolean()), working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")), slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch dueAt: v.optional(v.number()), // ms since epoch
@ -78,7 +96,8 @@ export default defineSchema({
.index("by_tenant_status", ["tenantId", "status"]) .index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"]) .index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"]) .index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"]) .index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant", ["tenantId"]), .index("by_tenant", ["tenantId"]),
ticketComments: defineTable({ ticketComments: defineTable({

View file

@ -1,4 +1,5 @@
import { mutation } from "./_generated/server"; import type { Id } from "./_generated/dataModel"
import { mutation } from "./_generated/server"
export const seedDemo = mutation({ export const seedDemo = mutation({
args: {}, args: {},
@ -51,30 +52,139 @@ export const seedDemo = mutation({
const queueChamados = queuesBySlug.get("chamados"); const queueChamados = queuesBySlug.get("chamados");
const queueLaboratorio = queuesBySlug.get("laboratorio"); const queueLaboratorio = queuesBySlug.get("laboratorio");
if (!queueChamados || !queueLaboratorio) { const queueVisitas = queuesBySlug.get("visitas");
if (!queueChamados || !queueLaboratorio || !queueVisitas) {
throw new Error("Filas padrão não foram inicializadas"); throw new Error("Filas padrão não foram inicializadas");
} }
// Ensure users // Ensure users
async function ensureUser(name: string, email: string, role = "AGENT") { function slugify(value: string) {
const found = await ctx.db return value
.query("users") .normalize("NFD")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) .replace(/[\u0300-\u036f]/g, "")
.first(); .replace(/[^\w\s-]/g, "")
if (found) { .trim()
const updates: Record<string, unknown> = {}; .replace(/\s+/g, "-")
if (found.name !== name) updates.name = name; .replace(/-+/g, "-")
if ((found.role ?? "AGENT") !== role) updates.role = role; .toLowerCase();
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 adminId = await ensureUser("Administrador", "admin@sistema.dev", "ADMIN");
function defaultAvatar(name: string, email: string, role: string) {
const normalizedRole = role.toUpperCase();
if (normalizedRole === "CUSTOMER" || normalizedRole === "MANAGER") {
return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`;
}
const first = name.split(" ")[0] ?? email;
return `https://avatar.vercel.sh/${encodeURIComponent(first)}`;
}
async function ensureCompany(def: {
name: string;
slug?: string;
cnpj?: string;
domain?: string;
phone?: string;
description?: string;
address?: string;
}): Promise<Id<"companies">> {
const slug = def.slug ?? slugify(def.name);
const existing = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
const now = Date.now();
const payload = {
tenantId,
name: def.name,
slug,
cnpj: def.cnpj ?? undefined,
domain: def.domain ?? undefined,
phone: def.phone ?? undefined,
description: def.description ?? undefined,
address: def.address ?? undefined,
createdAt: now,
updatedAt: now,
};
if (existing) {
const updates: Record<string, unknown> = {};
if (existing.name !== payload.name) updates.name = payload.name;
if (existing.cnpj !== payload.cnpj) updates.cnpj = payload.cnpj;
if (existing.domain !== payload.domain) updates.domain = payload.domain;
if (existing.phone !== payload.phone) updates.phone = payload.phone;
if (existing.description !== payload.description) updates.description = payload.description;
if (existing.address !== payload.address) updates.address = payload.address;
if (Object.keys(updates).length > 0) {
updates.updatedAt = now;
await ctx.db.patch(existing._id, updates);
}
return existing._id;
}
return await ctx.db.insert("companies", payload);
}
async function ensureUser(params: {
name: string;
email: string;
role?: string;
companyId?: Id<"companies">;
avatarUrl?: string;
}): Promise<Id<"users">> {
const normalizedEmail = params.email.trim().toLowerCase();
const normalizedRole = (params.role ?? "CUSTOMER").toUpperCase();
const desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole);
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedEmail))
.first();
if (existing) {
const updates: Record<string, unknown> = {};
if (existing.name !== params.name) updates.name = params.name;
if ((existing.role ?? "CUSTOMER") !== normalizedRole) updates.role = normalizedRole;
if ((existing.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar;
if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(existing._id, updates);
}
return existing._id;
}
return await ctx.db.insert("users", {
tenantId,
name: params.name,
email: normalizedEmail,
role: normalizedRole,
avatarUrl: desiredAvatar,
companyId: params.companyId ?? undefined,
});
}
const companiesSeed = [
{
name: "Atlas Engenharia Digital",
slug: "atlas-engenharia",
cnpj: "12.345.678/0001-90",
domain: "atlasengenharia.com.br",
phone: "+55 11 4002-8922",
description: "Transformação digital para empresas de engenharia e construção.",
address: "Av. Paulista, 1234 - Bela Vista, São Paulo/SP",
},
{
name: "Omni Saúde Integrada",
slug: "omni-saude",
cnpj: "45.678.912/0001-05",
domain: "omnisaude.com.br",
phone: "+55 31 3555-7788",
description: "Rede de clínicas com serviços de telemedicina e prontuário eletrônico.",
address: "Rua da Bahia, 845 - Centro, Belo Horizonte/MG",
},
];
const companyIds = new Map<string, Id<"companies">>();
for (const company of companiesSeed) {
const id = await ensureCompany(company);
companyIds.set(company.slug, id);
}
const adminId = await ensureUser({ name: "Administrador", email: "admin@sistema.dev", role: "ADMIN" });
const staffRoster = [ const staffRoster = [
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" }, { name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
{ name: "George Araujo", email: "george.araujo@rever.com.br" }, { name: "George Araujo", email: "george.araujo@rever.com.br" },
@ -86,10 +196,62 @@ export const seedDemo = mutation({
{ name: "Weslei Magalhães", email: "weslei@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 staffIds = await Promise.all(
staffRoster.map((staff) => ensureUser({ name: staff.name, email: staff.email, role: "AGENT" })),
);
const defaultAssigneeId = staffIds[0] ?? adminId; 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 atlasCompanyId = companyIds.get("atlas-engenharia");
const omniCompanyId = companyIds.get("omni-saude");
if (!atlasCompanyId || !omniCompanyId) {
throw new Error("Empresas padrão não foram inicializadas");
}
const atlasManagerId = await ensureUser({
name: "Mariana Andrade",
email: "mariana.andrade@atlasengenharia.com.br",
role: "MANAGER",
companyId: atlasCompanyId,
});
const joaoAtlasId = await ensureUser({
name: "João Pedro Ramos",
email: "joao.ramos@atlasengenharia.com.br",
role: "CUSTOMER",
companyId: atlasCompanyId,
});
await ensureUser({
name: "Aline Rezende",
email: "aline.rezende@atlasengenharia.com.br",
role: "CUSTOMER",
companyId: atlasCompanyId,
});
const omniManagerId = await ensureUser({
name: "Fernanda Lima",
email: "fernanda.lima@omnisaude.com.br",
role: "MANAGER",
companyId: omniCompanyId,
});
const ricardoOmniId = await ensureUser({
name: "Ricardo Matos",
email: "ricardo.matos@omnisaude.com.br",
role: "CUSTOMER",
companyId: omniCompanyId,
});
await ensureUser({
name: "Luciana Prado",
email: "luciana.prado@omnisaude.com.br",
role: "CUSTOMER",
companyId: omniCompanyId,
});
const clienteDemoId = await ensureUser({
name: "Cliente Demo",
email: "cliente.demo@sistema.dev",
role: "CUSTOMER",
companyId: omniCompanyId,
});
const templateDefinitions = [ const templateDefinitions = [
{ {
@ -146,13 +308,25 @@ export const seedDemo = mutation({
priority: "URGENT", priority: "URGENT",
channel: "EMAIL", channel: "EMAIL",
queueId: queue1, queueId: queue1,
requesterId: eduardaId, requesterId: joaoAtlasId,
assigneeId: defaultAssigneeId, assigneeId: defaultAssigneeId,
companyId: atlasCompanyId,
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"],
}); });
await ctx.db.insert("ticketEvents", { ticketId: t1, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 5, payload: {} }); await ctx.db.insert("ticketEvents", {
ticketId: t1,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 5,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t1,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 4,
payload: { managerUserId: atlasManagerId },
});
const t2 = await ctx.db.insert("tickets", { const t2 = await ctx.db.insert("tickets", {
tenantId, tenantId,
@ -163,13 +337,54 @@ export const seedDemo = mutation({
priority: "HIGH", priority: "HIGH",
channel: "WHATSAPP", channel: "WHATSAPP",
queueId: queue2, queueId: queue2,
requesterId: clienteDemoId, requesterId: ricardoOmniId,
assigneeId: defaultAssigneeId, assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
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"],
}); });
await ctx.db.insert("ticketEvents", { ticketId: t2, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 8, payload: {} }); await ctx.db.insert("ticketEvents", {
ticketId: t2,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 8,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t2,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 7,
payload: { managerUserId: omniManagerId },
});
const t3 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Visita técnica para instalação de roteadores",
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
status: "OPEN",
priority: "MEDIUM",
channel: "PHONE",
queueId: queueVisitas._id,
requesterId: clienteDemoId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
createdAt: now - 1000 * 60 * 60 * 3,
updatedAt: now - 1000 * 60 * 20,
tags: ["visita", "infraestrutura"],
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 3,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "VISIT_SCHEDULED",
createdAt: now - 1000 * 60 * 15,
payload: { scheduledFor: now + 1000 * 60 * 60 * 24 },
});
}, },
}); });

View file

@ -1,11 +1,42 @@
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel"; import { Id, type Doc } from "./_generated/dataModel";
import { requireCustomer, requireStaff, requireUser } from "./rbac"; import { requireCustomer, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx,
manager: Doc<"users">,
ticket: Doc<"tickets">,
): Promise<Doc<"users"> | null> {
if (!manager.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (ticket.companyId && ticket.companyId === manager.companyId) {
return null
}
const requester = await ctx.db.get(ticket.requesterId)
if (!requester || requester.companyId !== manager.companyId) {
throw new ConvexError("Acesso restrito à empresa")
}
return requester as Doc<"users">
}
async function requireTicketStaff(
ctx: MutationCtx | QueryCtx,
actorId: Id<"users">,
ticket: Doc<"tickets">
) {
const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
if (viewer.role === "MANAGER") {
await ensureManagerTicketAccess(ctx, viewer.user, ticket)
}
return viewer
}
const QUEUE_RENAME_LOOKUP: Record<string, string> = { const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados", "Suporte N1": "Chamados",
@ -188,11 +219,19 @@ export const list = query({
if (!args.viewerId) { if (!args.viewerId) {
return [] return []
} }
const { role } = await requireUser(ctx, args.viewerId, args.tenantId) const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency // Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = []; let base: Doc<"tickets">[] = [];
if (args.status) { if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
} else if (args.status) {
base = await ctx.db base = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!)) .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
@ -212,6 +251,11 @@ export const list = query({
if (role === "CUSTOMER") { if (role === "CUSTOMER") {
filtered = filtered.filter((t) => t.requesterId === args.viewerId); filtered = filtered.filter((t) => t.requesterId === args.viewerId);
} else if (role === "MANAGER") {
if (!user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
} }
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
@ -314,13 +358,19 @@ export const list = query({
export const getById = query({ export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") }, args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { tenantId, id, viewerId }) => { handler: async (ctx, { tenantId, id, viewerId }) => {
const { role } = await requireUser(ctx, viewerId, tenantId) const { user, role } = await requireUser(ctx, viewerId, tenantId)
const t = await ctx.db.get(id); const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null; if (!t || t.tenantId !== tenantId) return null;
if (role === "CUSTOMER" && t.requesterId !== viewerId) { if (role === "CUSTOMER" && t.requesterId !== viewerId) {
throw new ConvexError("Acesso restrito ao solicitante") throw new ConvexError("Acesso restrito ao solicitante")
} }
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; let requester: Doc<"users"> | null = null
if (role === "MANAGER") {
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
}
if (!requester) {
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
}
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const queueName = normalizeQueueName(queue); const queueName = normalizeQueueName(queue);
@ -479,7 +529,7 @@ export const create = mutation({
throw new ConvexError("Clientes só podem abrir chamados para si mesmos") throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
} }
if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) { if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
throw new ConvexError("Somente a equipe interna pode definir o responsável") throw new ConvexError("Somente a equipe interna pode definir o responsável")
} }
@ -497,7 +547,7 @@ export const create = mutation({
} }
initialAssigneeId = assignee._id initialAssigneeId = assignee._id
initialAssignee = assignee initialAssignee = assignee
} else if (role && STAFF_ROLES.has(role)) { } else if (role && INTERNAL_STAFF_ROLES.has(role)) {
initialAssigneeId = actorUser._id initialAssigneeId = actorUser._id
initialAssignee = actorUser initialAssignee = actorUser
} }
@ -515,6 +565,19 @@ export const create = mutation({
throw new ConvexError("Subcategoria inválida"); throw new ConvexError("Subcategoria inválida");
} }
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
if (!requester || requester.tenantId !== args.tenantId) {
throw new ConvexError("Solicitante inválido")
}
if (role === "MANAGER") {
if (!actorUser.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada")
}
if (requester.companyId !== actorUser.companyId) {
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
}
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined); const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant) // compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db const existing = await ctx.db
@ -537,6 +600,7 @@ export const create = mutation({
subcategoryId: args.subcategoryId, subcategoryId: args.subcategoryId,
requesterId: args.requesterId, requesterId: args.requesterId,
assigneeId: initialAssigneeId, assigneeId: initialAssigneeId,
companyId: requester.companyId ?? undefined,
working: false, working: false,
activeSessionId: undefined, activeSessionId: undefined,
totalWorkedMs: 0, totalWorkedMs: 0,
@ -550,7 +614,6 @@ export const create = mutation({
dueAt: undefined, dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
}); });
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: id, ticketId: id,
type: "CREATED", type: "CREATED",
@ -593,27 +656,35 @@ export const addComment = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
const ticketDoc = ticket as Doc<"tickets">
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
if (!author || author.tenantId !== ticket.tenantId) { if (!author || author.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido") throw new ConvexError("Autor do comentário inválido")
} }
const normalizedRole = (author.role ?? "AGENT").toUpperCase() const normalizedRole = (author.role ?? "AGENT").toUpperCase()
if (ticket.requesterId === args.authorId) { if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, author, ticketDoc)
if (args.visibility !== "PUBLIC") {
throw new ConvexError("Gestores só podem registrar comentários públicos")
}
}
if (ticketDoc.requesterId === args.authorId) {
if (normalizedRole === "CUSTOMER") { if (normalizedRole === "CUSTOMER") {
await requireCustomer(ctx, args.authorId, ticket.tenantId) await requireCustomer(ctx, args.authorId, ticketDoc.tenantId)
if (args.visibility !== "PUBLIC") { if (args.visibility !== "PUBLIC") {
throw new ConvexError("Clientes só podem registrar comentários públicos") throw new ConvexError("Clientes só podem registrar comentários públicos")
} }
} else if (STAFF_ROLES.has(normalizedRole)) { } else if (STAFF_ROLES.has(normalizedRole)) {
await requireStaff(ctx, args.authorId, ticket.tenantId) await requireTicketStaff(ctx, args.authorId, ticketDoc)
} else { } else {
throw new ConvexError("Autor não possui permissão para comentar") throw new ConvexError("Autor não possui permissão para comentar")
} }
} else { } else {
await requireStaff(ctx, args.authorId, ticket.tenantId) await requireTicketStaff(ctx, args.authorId, ticketDoc)
} }
const now = Date.now(); const now = Date.now();
@ -650,6 +721,7 @@ export const updateComment = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId); const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) { if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado"); throw new ConvexError("Comentário não encontrado");
@ -657,10 +729,10 @@ export const updateComment = mutation({
if (comment.authorId !== actorId) { if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário"); throw new ConvexError("Você não tem permissão para editar este comentário");
} }
if (ticket.requesterId === actorId) { if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId) await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else { } else {
await requireStaff(ctx, actorId, ticket.tenantId) await requireTicketStaff(ctx, actorId, ticketDoc)
} }
const now = Date.now(); const now = Date.now();
@ -698,6 +770,7 @@ export const removeCommentAttachment = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId); const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) { if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado"); throw new ConvexError("Comentário não encontrado");
@ -706,10 +779,10 @@ export const removeCommentAttachment = mutation({
throw new ConvexError("Você não pode alterar anexos de outro usuário") throw new ConvexError("Você não pode alterar anexos de outro usuário")
} }
if (ticket.requesterId === actorId) { if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId) await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else { } else {
await requireStaff(ctx, actorId, ticket.tenantId) await requireTicketStaff(ctx, actorId, ticketDoc)
} }
const attachments = comment.attachments ?? []; const attachments = comment.attachments ?? [];
@ -751,7 +824,8 @@ export const updateStatus = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
await requireStaff(ctx, actorId, ticket.tenantId) const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now }); await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = { const statusPt: Record<string, string> = {
@ -778,11 +852,15 @@ export const changeAssignee = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
await requireStaff(ctx, actorId, ticket.tenantId) const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticket.tenantId) { if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Responsável inválido") throw new ConvexError("Responsável inválido")
} }
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now }); await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
@ -801,9 +879,13 @@ export const changeQueue = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
await requireStaff(ctx, actorId, ticket.tenantId) const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a fila do chamado")
}
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticket.tenantId) { if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida") throw new ConvexError("Fila inválida")
} }
const now = Date.now(); const now = Date.now();
@ -830,13 +912,17 @@ export const updateCategories = mutation({
if (!ticket) { if (!ticket) {
throw new ConvexError("Ticket não encontrado") throw new ConvexError("Ticket não encontrado")
} }
await requireStaff(ctx, actorId, ticket.tenantId) const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
}
if (categoryId === null) { if (categoryId === null) {
if (subcategoryId !== null) { if (subcategoryId !== null) {
throw new ConvexError("Subcategoria inválida") throw new ConvexError("Subcategoria inválida")
} }
if (!ticket.categoryId && !ticket.subcategoryId) { if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
return { status: "unchanged" } return { status: "unchanged" }
} }
const now = Date.now() const now = Date.now()
@ -864,20 +950,20 @@ export const updateCategories = mutation({
} }
const category = await ctx.db.get(categoryId) const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticket.tenantId) { if (!category || category.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Categoria inválida") throw new ConvexError("Categoria inválida")
} }
let subcategoryName: string | null = null let subcategoryName: string | null = null
if (subcategoryId !== null) { if (subcategoryId !== null) {
const subcategory = await ctx.db.get(subcategoryId) const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) { if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Subcategoria inválida") throw new ConvexError("Subcategoria inválida")
} }
subcategoryName = subcategory.name subcategoryName = subcategory.name
} }
if (ticket.categoryId === categoryId && (ticket.subcategoryId ?? null) === subcategoryId) { if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
return { status: "unchanged" } return { status: "unchanged" }
} }

View file

@ -0,0 +1,121 @@
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"cnpj" TEXT,
"domain" TEXT,
"phone" TEXT,
"description" TEXT,
"address" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AuthInvite" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"name" TEXT,
"role" TEXT NOT NULL DEFAULT 'agent',
"tenantId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdById" TEXT,
"acceptedAt" DATETIME,
"acceptedById" TEXT,
"revokedAt" DATETIME,
"revokedById" TEXT,
"revokedReason" TEXT
);
-- CreateTable
CREATE TABLE "AuthInviteEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"inviteId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB,
"actorId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuthInviteEvent_inviteId_fkey" FOREIGN KEY ("inviteId") REFERENCES "AuthInvite" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Ticket" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"reference" INTEGER NOT NULL DEFAULT 0,
"subject" TEXT NOT NULL,
"summary" TEXT,
"status" TEXT NOT NULL DEFAULT 'NEW',
"priority" TEXT NOT NULL DEFAULT 'MEDIUM',
"channel" TEXT NOT NULL DEFAULT 'EMAIL',
"queueId" TEXT,
"requesterId" TEXT NOT NULL,
"assigneeId" TEXT,
"slaPolicyId" TEXT,
"companyId" TEXT,
"dueAt" DATETIME,
"firstResponseAt" DATETIME,
"resolvedAt" DATETIME,
"closedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Ticket_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Ticket" ("assigneeId", "channel", "closedAt", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt") SELECT "assigneeId", "channel", "closedAt", "createdAt", "dueAt", "firstResponseAt", "id", "priority", "queueId", "reference", "requesterId", "resolvedAt", "slaPolicyId", "status", "subject", "summary", "tenantId", "updatedAt" FROM "Ticket";
DROP TABLE "Ticket";
ALTER TABLE "new_Ticket" RENAME TO "Ticket";
CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status");
CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId");
CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId");
CREATE INDEX "Ticket_tenantId_companyId_idx" ON "Ticket"("tenantId", "companyId");
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
"avatarUrl" TEXT,
"companyId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_User" ("avatarUrl", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt") SELECT "avatarUrl", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role");
CREATE INDEX "User_tenantId_companyId_idx" ON "User"("tenantId", "companyId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "Company_tenantId_name_idx" ON "Company"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "Company_tenantId_slug_key" ON "Company"("tenantId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "AuthInvite_token_key" ON "AuthInvite"("token");
-- CreateIndex
CREATE INDEX "AuthInvite_tenantId_status_idx" ON "AuthInvite"("tenantId", "status");
-- CreateIndex
CREATE INDEX "AuthInvite_tenantId_email_idx" ON "AuthInvite"("tenantId", "email");
-- CreateIndex
CREATE INDEX "AuthInviteEvent_inviteId_createdAt_idx" ON "AuthInviteEvent"("inviteId", "createdAt");

View file

@ -70,6 +70,26 @@ model TeamMember {
@@id([teamId, userId]) @@id([teamId, userId])
} }
model Company {
id String @id @default(cuid())
tenantId String
name String
slug String
cnpj String?
domain String?
phone String?
description String?
address String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
tickets Ticket[]
@@unique([tenantId, slug])
@@index([tenantId, name])
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
@ -78,6 +98,7 @@ model User {
role UserRole role UserRole
timezone String @default("America/Sao_Paulo") timezone String @default("America/Sao_Paulo")
avatarUrl String? avatarUrl String?
companyId String?
teams TeamMember[] teams TeamMember[]
requestedTickets Ticket[] @relation("TicketRequester") requestedTickets Ticket[] @relation("TicketRequester")
assignedTickets Ticket[] @relation("TicketAssignee") assignedTickets Ticket[] @relation("TicketAssignee")
@ -85,7 +106,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id])
@@index([tenantId, role]) @@index([tenantId, role])
@@index([tenantId, companyId])
} }
model Queue { model Queue {
@ -116,6 +140,7 @@ model Ticket {
requesterId String requesterId String
assigneeId String? assigneeId String?
slaPolicyId String? slaPolicyId String?
companyId String?
dueAt DateTime? dueAt DateTime?
firstResponseAt DateTime? firstResponseAt DateTime?
resolvedAt DateTime? resolvedAt DateTime?
@ -127,12 +152,14 @@ model Ticket {
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id]) assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id]) queue Queue? @relation(fields: [queueId], references: [id])
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id]) slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
company Company? @relation(fields: [companyId], references: [id])
events TicketEvent[] events TicketEvent[]
comments TicketComment[] comments TicketComment[]
@@index([tenantId, status]) @@index([tenantId, status])
@@index([tenantId, queueId]) @@index([tenantId, queueId])
@@index([tenantId, assigneeId]) @@index([tenantId, assigneeId])
@@index([tenantId, companyId])
} }
model TicketEvent { model TicketEvent {

View file

@ -41,7 +41,58 @@ function toDate(value) {
return new Date(value) return new Date(value)
} }
async function upsertUsers(snapshotUsers) { function slugify(value) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase()
}
async function upsertCompanies(snapshotCompanies) {
const map = new Map()
for (const company of snapshotCompanies) {
const slug = company.slug || slugify(company.name)
const record = await prisma.company.upsert({
where: {
tenantId_slug: {
tenantId,
slug,
},
},
update: {
name: company.name,
cnpj: company.cnpj ?? null,
domain: company.domain ?? null,
phone: company.phone ?? null,
description: company.description ?? null,
address: company.address ?? null,
},
create: {
tenantId,
name: company.name,
slug,
cnpj: company.cnpj ?? null,
domain: company.domain ?? null,
phone: company.phone ?? null,
description: company.description ?? null,
address: company.address ?? null,
createdAt: toDate(company.createdAt) ?? new Date(),
updatedAt: toDate(company.updatedAt) ?? new Date(),
},
})
map.set(slug, record.id)
}
return map
}
async function upsertUsers(snapshotUsers, companyMap) {
const map = new Map() const map = new Map()
for (const user of snapshotUsers) { for (const user of snapshotUsers) {
@ -50,6 +101,7 @@ async function upsertUsers(snapshotUsers) {
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase() const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER" const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null
const record = await prisma.user.upsert({ const record = await prisma.user.upsert({
where: { email: normalizedEmail }, where: { email: normalizedEmail },
@ -58,6 +110,7 @@ async function upsertUsers(snapshotUsers) {
role, role,
tenantId, tenantId,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
companyId,
}, },
create: { create: {
email: normalizedEmail, email: normalizedEmail,
@ -65,6 +118,7 @@ async function upsertUsers(snapshotUsers) {
role, role,
tenantId, tenantId,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
companyId,
}, },
}) })
@ -80,6 +134,7 @@ async function upsertUsers(snapshotUsers) {
name: staff.name, name: staff.name,
role: staff.role, role: staff.role,
tenantId, tenantId,
companyId: null,
}, },
create: { create: {
email: normalizedEmail, email: normalizedEmail,
@ -87,6 +142,7 @@ async function upsertUsers(snapshotUsers) {
role: staff.role, role: staff.role,
tenantId, tenantId,
avatarUrl: null, avatarUrl: null,
companyId: null,
}, },
}) })
map.set(normalizedEmail, record.id) map.set(normalizedEmail, record.id)
@ -97,7 +153,7 @@ async function upsertUsers(snapshotUsers) {
const removableStaff = await prisma.user.findMany({ const removableStaff = await prisma.user.findMany({
where: { where: {
tenantId, tenantId,
role: { in: ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] }, role: { in: ["ADMIN", "AGENT", "COLLABORATOR"] },
email: { email: {
notIn: Array.from(allowedStaffEmails), notIn: Array.from(allowedStaffEmails),
}, },
@ -157,7 +213,7 @@ async function upsertQueues(snapshotQueues) {
return map return map
} }
async function upsertTickets(snapshotTickets, userMap, queueMap) { async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
let created = 0 let created = 0
let updated = 0 let updated = 0
@ -171,6 +227,15 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null
let companyId = ticket.companySlug ? companyMap.get(ticket.companySlug) ?? null : null
if (!companyId && requesterId) {
const requester = await prisma.user.findUnique({
where: { id: requesterId },
select: { companyId: true },
})
companyId = requester?.companyId ?? null
}
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail) const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
@ -196,6 +261,7 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
closedAt: toDate(ticket.closedAt), closedAt: toDate(ticket.closedAt),
createdAt: toDate(ticket.createdAt) ?? new Date(), createdAt: toDate(ticket.createdAt) ?? new Date(),
updatedAt: toDate(ticket.updatedAt) ?? new Date(), updatedAt: toDate(ticket.updatedAt) ?? new Date(),
companyId,
} }
let ticketRecord let ticketRecord
@ -264,12 +330,17 @@ async function run() {
tenantId, tenantId,
}) })
console.log(`Empresas recebidas: ${snapshot.companies.length}`)
console.log(`Usuários recebidos: ${snapshot.users.length}`) console.log(`Usuários recebidos: ${snapshot.users.length}`)
console.log(`Filas recebidas: ${snapshot.queues.length}`) console.log(`Filas recebidas: ${snapshot.queues.length}`)
console.log(`Tickets recebidos: ${snapshot.tickets.length}`) console.log(`Tickets recebidos: ${snapshot.tickets.length}`)
console.log("Sincronizando empresas no Prisma...")
const companyMap = await upsertCompanies(snapshot.companies)
console.log(`Empresas ativas no mapa: ${companyMap.size}`)
console.log("Sincronizando usuários no Prisma...") console.log("Sincronizando usuários no Prisma...")
const userMap = await upsertUsers(snapshot.users) const userMap = await upsertUsers(snapshot.users, companyMap)
console.log(`Usuários ativos no mapa: ${userMap.size}`) console.log(`Usuários ativos no mapa: ${userMap.size}`)
console.log("Sincronizando filas no Prisma...") console.log("Sincronizando filas no Prisma...")
@ -277,7 +348,7 @@ async function run() {
console.log(`Filas ativas no mapa: ${queueMap.size}`) console.log(`Filas ativas no mapa: ${queueMap.size}`)
console.log("Sincronizando tickets no Prisma...") console.log("Sincronizando tickets no Prisma...")
const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap) const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap, companyMap)
console.log(`Tickets criados: ${ticketStats.created}`) console.log(`Tickets criados: ${ticketStats.created}`)
console.log(`Tickets atualizados: ${ticketStats.updated}`) console.log(`Tickets atualizados: ${ticketStats.updated}`)
} }

View file

@ -31,6 +31,48 @@ const defaultUsers = singleUserFromEnv ?? [
role: "customer", role: "customer",
tenantId, tenantId,
}, },
{
email: "mariana.andrade@atlasengenharia.com.br",
password: "manager123",
name: "Mariana Andrade",
role: "manager",
tenantId,
},
{
email: "fernanda.lima@omnisaude.com.br",
password: "manager123",
name: "Fernanda Lima",
role: "manager",
tenantId,
},
{
email: "joao.ramos@atlasengenharia.com.br",
password: "cliente123",
name: "João Pedro Ramos",
role: "customer",
tenantId,
},
{
email: "aline.rezende@atlasengenharia.com.br",
password: "cliente123",
name: "Aline Rezende",
role: "customer",
tenantId,
},
{
email: "ricardo.matos@omnisaude.com.br",
password: "cliente123",
name: "Ricardo Matos",
role: "customer",
tenantId,
},
{
email: "luciana.prado@omnisaude.com.br",
password: "cliente123",
name: "Luciana Prado",
role: "customer",
tenantId,
},
{ {
email: "gabriel.oliveira@rever.com.br", email: "gabriel.oliveira@rever.com.br",
password: "agent123", password: "agent123",

View file

@ -30,12 +30,13 @@ async function main() {
process.exit(1) process.exit(1)
} }
const [users, queues, tickets] = await Promise.all([ const [users, queues, tickets, companies] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
include: { include: {
teams: { teams: {
include: { team: true }, include: { team: true },
}, },
company: true,
}, },
}), }),
prisma.queue.findMany(), prisma.queue.findMany(),
@ -44,6 +45,7 @@ async function main() {
requester: true, requester: true,
assignee: true, assignee: true,
queue: true, queue: true,
company: true,
comments: { comments: {
include: { include: {
author: true, author: true,
@ -53,6 +55,7 @@ async function main() {
}, },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}), }),
prisma.company.findMany(),
]) ])
const userSnapshot = users.map((user) => ({ const userSnapshot = users.map((user) => ({
@ -63,6 +66,7 @@ async function main() {
teams: user.teams teams: user.teams
.map((membership) => membership.team?.name) .map((membership) => membership.team?.name)
.filter((name) => Boolean(name) && typeof name === "string"), .filter((name) => Boolean(name) && typeof name === "string"),
companySlug: user.company?.slug ?? undefined,
})) }))
const queueSnapshot = queues.map((queue) => ({ const queueSnapshot = queues.map((queue) => ({
@ -78,6 +82,7 @@ async function main() {
const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com" const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com"
const assigneeEmail = ticket.assignee?.email ?? undefined const assigneeEmail = ticket.assignee?.email ?? undefined
const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name) const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name)
const companySlug = ticket.company?.slug ?? ticket.requester?.company?.slug ?? undefined
return { return {
reference, reference,
@ -89,6 +94,7 @@ async function main() {
queueSlug: queueSlug ?? undefined, queueSlug: queueSlug ?? undefined,
requesterEmail, requesterEmail,
assigneeEmail, assigneeEmail,
companySlug,
dueAt: toMillis(ticket.dueAt) ?? undefined, dueAt: toMillis(ticket.dueAt) ?? undefined,
firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined, firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined,
resolvedAt: toMillis(ticket.resolvedAt) ?? undefined, resolvedAt: toMillis(ticket.resolvedAt) ?? undefined,
@ -111,12 +117,25 @@ async function main() {
} }
}) })
const companySnapshot = companies.map((company) => ({
slug: company.slug ?? slugify(company.name),
name: company.name,
cnpj: company.cnpj ?? undefined,
domain: company.domain ?? undefined,
phone: company.phone ?? undefined,
description: company.description ?? undefined,
address: company.address ?? undefined,
createdAt: toMillis(company.createdAt) ?? Date.now(),
updatedAt: toMillis(company.updatedAt) ?? Date.now(),
}))
const client = new ConvexHttpClient(convexUrl) const client = new ConvexHttpClient(convexUrl)
const result = await client.mutation("migrations:importPrismaSnapshot", { const result = await client.mutation("migrations:importPrismaSnapshot", {
secret, secret,
snapshot: { snapshot: {
tenantId, tenantId,
companies: companySnapshot,
users: userSnapshot, users: userSnapshot,
queues: queueSnapshot, queues: queueSnapshot,
tickets: ticketSnapshot, tickets: ticketSnapshot,

View file

@ -1,28 +1,6 @@
import { AppShell } from "@/components/app-shell" import { TicketsPageClient } from "./tickets-page-client"
import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" export default function TicketsPage() {
import { TicketsView } from "@/components/tickets/tickets-view" return <TicketsPageClient />
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog" }
export default function TicketsPage() {
return (
<AppShell
header={
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>
<div className="flex flex-col gap-6">
<div className="px-4 lg:px-6">
<TicketQueueSummaryCards />
</div>
<TicketsView />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,52 @@
"use client"
import dynamic from "next/dynamic"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
const TicketQueueSummaryCards = dynamic(
() =>
import("@/components/tickets/ticket-queue-summary").then((module) => ({
default: module.TicketQueueSummaryCards,
})),
{ ssr: false }
)
const TicketsView = dynamic(
() =>
import("@/components/tickets/tickets-view").then((module) => ({
default: module.TicketsView,
})),
{ ssr: false }
)
const NewTicketDialog = dynamic(
() =>
import("@/components/tickets/new-ticket-dialog").then((module) => ({
default: module.NewTicketDialog,
})),
{ ssr: false }
)
export function TicketsPageClient() {
return (
<AppShell
header={
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>
<div className="flex flex-col gap-6">
<div className="px-4 lg:px-6">
<TicketQueueSummaryCards />
</div>
<TicketsView />
</div>
</AppShell>
)
}

View file

@ -34,7 +34,8 @@ 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, isStaff } = useAuth() const { convexUserId, isStaff, role } = useAuth()
const isManager = role === "manager"
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)
@ -119,6 +120,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
event.preventDefault() event.preventDefault()
if (!convexUserId) return if (!convexUserId) return
const now = new Date() const now = new Date()
const selectedVisibility = isManager ? "PUBLIC" : visibility
const attachments = attachmentsToSend.map((item) => ({ ...item })) const attachments = attachmentsToSend.map((item) => ({ ...item }))
const previewsToRevoke = attachments const previewsToRevoke = attachments
.map((attachment) => attachment.previewUrl) .map((attachment) => attachment.previewUrl)
@ -126,7 +128,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const optimistic = { const optimistic = {
id: `temp-${now.getTime()}`, id: `temp-${now.getTime()}`,
author: ticket.requester, author: ticket.requester,
visibility, visibility: selectedVisibility,
body: sanitizeEditorHtml(body), body: sanitizeEditorHtml(body),
attachments: attachments.map((attachment) => ({ attachments: attachments.map((attachment) => ({
id: attachment.storageId, id: attachment.storageId,
@ -153,7 +155,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
await addComment({ await addComment({
ticketId: ticket.id as Id<"tickets">, ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">, authorId: convexUserId as Id<"users">,
visibility, visibility: selectedVisibility,
body: optimistic.body, body: optimistic.body,
attachments: payload, attachments: payload,
}) })
@ -414,13 +416,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
) : null} ) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
Visibilidade: Visibilidade:
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}> <Select
value={visibility}
onValueChange={(value) => {
if (isManager) return
setVisibility(value as "PUBLIC" | "INTERNAL")
}}
disabled={isManager}
>
<SelectTrigger className={selectTriggerClass}> <SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" /> <SelectValue placeholder="Visibilidade" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm"> <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem> <SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem> {!isManager ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View file

@ -60,7 +60,8 @@ function formatDuration(durationMs: number) {
} }
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { convexUserId } = useAuth() const { convexUserId, role } = useAuth()
const isManager = role === "manager"
const changeAssignee = useMutation(api.tickets.changeAssignee) const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue) const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject) const updateSubject = useMutation(api.tickets.updateSubject)
@ -129,7 +130,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setSaving(true) setSaving(true)
try { try {
if (categoryDirty) { if (categoryDirty && !isManager) {
toast.loading("Atualizando categoria...", { id: "ticket-category" }) toast.loading("Atualizando categoria...", { id: "ticket-category" })
try { try {
await updateCategories({ await updateCategories({
@ -147,6 +148,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}) })
throw categoryError throw categoryError
} }
} else if (categoryDirty && isManager) {
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
} }
if (dirty) { if (dirty) {
@ -333,9 +339,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionLabelClass}>Categoria primária</span> <span className={sectionLabelClass}>Categoria primária</span>
{editing ? ( {editing ? (
<Select <Select
disabled={saving || categoriesLoading} disabled={saving || categoriesLoading || isManager}
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE} value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
onValueChange={(value) => { onValueChange={(value) => {
if (isManager) return
if (value === EMPTY_CATEGORY_VALUE) { if (value === EMPTY_CATEGORY_VALUE) {
setCategorySelection({ categoryId: "", subcategoryId: "" }) setCategorySelection({ categoryId: "", subcategoryId: "" })
return return
@ -368,10 +375,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
{editing ? ( {editing ? (
<Select <Select
disabled={ disabled={
saving || categoriesLoading || !selectedCategoryId saving || categoriesLoading || !selectedCategoryId || isManager
} }
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE} value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
onValueChange={(value) => { onValueChange={(value) => {
if (isManager) return
if (value === EMPTY_SUBCATEGORY_VALUE) { if (value === EMPTY_SUBCATEGORY_VALUE) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" })) setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
return return
@ -407,9 +415,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionLabelClass}>Fila</span> <span className={sectionLabelClass}>Fila</span>
{editing ? ( {editing ? (
<Select <Select
disabled={isManager}
value={ticket.queue ?? ""} value={ticket.queue ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!convexUserId) return if (!convexUserId) return
if (isManager) return
const queue = queues.find((item) => item.name === value) const queue = queues.find((item) => item.name === value)
if (!queue) return if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" }) toast.loading("Atualizando fila...", { id: "queue" })
@ -444,9 +454,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionLabelClass}>Responsável</span> <span className={sectionLabelClass}>Responsável</span>
{editing ? ( {editing ? (
<Select <Select
disabled={isManager}
value={ticket.assignee?.id ?? ""} value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!convexUserId) return if (!convexUserId) return
if (isManager) return
toast.loading("Atribuindo responsável...", { id: "assignee" }) toast.loading("Atribuindo responsável...", { id: "assignee" })
try { try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> }) await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })