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
- [ ] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
- [ ] Ver todos os chamados da sua empresa
- [ ] Acessar relatórios e dashboards resumidos
- [x] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
- [x] Ver todos os chamados da sua empresa
- [x] Acessar relatórios e dashboards resumidos
- [ ] 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 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) {
return value.trim().toLowerCase()
@ -18,6 +19,7 @@ type ImportedUser = {
role?: string | null
avatarUrl?: string | null
teams?: string[] | null
companySlug?: string | null
}
type ImportedQueue = {
@ -25,11 +27,22 @@ type ImportedQueue = {
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) {
if (!role) return "AGENT"
const normalized = role.toUpperCase()
if (STAFF_ROLES.has(normalized)) return normalized
if (normalized === "CUSTOMER") return "CUSTOMER"
if (VALID_ROLES.has(normalized)) return normalized
return "AGENT"
}
@ -57,7 +70,8 @@ async function ensureUser(
ctx: MutationCtx,
tenantId: string,
data: ImportedUser,
cache: Map<string, Id<"users">>
cache: Map<string, Id<"users">>,
companyCache: Map<string, Id<"companies">>
) {
if (cache.has(data.email)) {
return cache.get(data.email)!
@ -68,13 +82,15 @@ async function ensureUser(
.first()
const role = normalizeRole(data.role)
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
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 ?? [])
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
(existing.companyId ?? undefined) !== companyId
if (needsPatch) {
return ctx.db.patch(existing._id, {
name: data.name,
@ -82,6 +98,7 @@ async function ensureUser(
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
tenantId,
companyId,
})
}
return Promise.resolve()
@ -93,6 +110,7 @@ async function ensureUser(
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
companyId,
})
const id = existing ? existing._id : ((await record) as Id<"users">)
@ -144,6 +162,64 @@ async function ensureQueue(
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) {
return ctx.db
.query("users")
@ -158,6 +234,13 @@ async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
.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({
args: {
secret: v.string(),
@ -168,10 +251,15 @@ export const exportTenantSnapshot = query({
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 queueMap = new Map(queues.map((queue) => [queue._id, queue]))
const companyMap = new Map(companies.map((company) => [company._id, company]))
const tickets = await ctx.db
.query("tickets")
@ -194,6 +282,11 @@ export const exportTenantSnapshot = query({
const requester = userMap.get(ticket.requesterId)
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : 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) {
continue
@ -209,6 +302,7 @@ export const exportTenantSnapshot = query({
queueSlug: queue?.slug ?? undefined,
requesterEmail: requester.email,
assigneeEmail: assignee?.email ?? undefined,
companySlug: company?.slug ?? undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
@ -247,12 +341,24 @@ export const exportTenantSnapshot = query({
return {
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) => ({
email: user.email,
name: user.name,
role: user.role ?? null,
avatarUrl: user.avatarUrl ?? null,
teams: user.teams ?? [],
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
})),
queues: queues.map((queue) => ({
name: queue.name,
@ -268,6 +374,19 @@ export const importPrismaSnapshot = mutation({
secret: v.string(),
snapshot: v.object({
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(
v.object({
email: v.string(),
@ -275,6 +394,7 @@ export const importPrismaSnapshot = mutation({
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companySlug: v.optional(v.string()),
})
),
queues: v.array(
@ -294,6 +414,7 @@ export const importPrismaSnapshot = mutation({
queueSlug: v.optional(v.string()),
requesterEmail: v.string(),
assigneeEmail: v.optional(v.string()),
companySlug: v.optional(v.string()),
dueAt: v.optional(v.number()),
firstResponseAt: 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")
}
const companyCache = new Map<string, Id<"companies">>()
const userCache = new Map<string, Id<"users">>()
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) {
await ensureUser(ctx, snapshot.tenantId, user, userCache)
await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
}
for (const queue of snapshot.queues) {
@ -342,7 +468,7 @@ export const importPrismaSnapshot = mutation({
const snapshotStaffEmails = new Set(
snapshot.users
.filter((user) => normalizeRole(user.role ?? null) !== "CUSTOMER")
.filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
.map((user) => normalizeEmail(user.email))
)
@ -353,7 +479,7 @@ export const importPrismaSnapshot = mutation({
for (const user of existingTenantUsers) {
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)
}
}
@ -370,7 +496,8 @@ export const importPrismaSnapshot = mutation({
email: ticket.requesterEmail,
name: ticket.requesterEmail,
},
userCache
userCache,
companyCache
)
const assigneeId = ticket.assigneeEmail
? await ensureUser(
@ -380,11 +507,13 @@ export const importPrismaSnapshot = mutation({
email: ticket.assigneeEmail,
name: ticket.assigneeEmail,
},
userCache
userCache,
companyCache
)
: 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
.query("tickets")
@ -406,6 +535,7 @@ export const importPrismaSnapshot = mutation({
assigneeId: assigneeId as Id<"users"> | undefined,
working: false,
slaPolicyId: undefined,
companyId: companyId as Id<"companies"> | undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
@ -452,7 +582,8 @@ export const importPrismaSnapshot = mutation({
email: comment.authorEmail,
name: comment.authorEmail,
},
userCache
userCache,
companyCache
)
await ctx.db.insert("ticketComments", {
ticketId,

View file

@ -5,6 +5,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const CUSTOMER_ROLE = "CUSTOMER"
const MANAGER_ROLE = "MANAGER"
type Ctx = QueryCtx | MutationCtx
@ -51,3 +52,30 @@ export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?:
}
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 type { QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac";
@ -42,6 +42,25 @@ async function fetchTickets(ctx: QueryCtx, tenantId: string) {
.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) {
return ctx.db
.query("queues")
@ -92,8 +111,8 @@ function formatDateKey(timestamp: number) {
export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const queues = await fetchQueues(ctx, tenantId);
const now = Date.now();
@ -140,8 +159,8 @@ export const slaOverview = query({
export const csatOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score));
@ -171,8 +190,8 @@ export const csatOverview = query({
export const backlogOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
@ -218,8 +237,8 @@ export const backlogOverview = query({
export const dashboardOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const now = Date.now();
const lastDayStart = now - ONE_DAY_MS;
@ -294,8 +313,8 @@ export const ticketsByChannel = query({
range: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, range }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date();

View file

@ -9,9 +9,26 @@ export default defineSchema({
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companyId: v.optional(v.id("companies")),
})
.index("by_tenant_email", ["tenantId", "email"])
.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"]),
queues: defineTable({
@ -51,6 +68,7 @@ export default defineSchema({
subcategoryId: v.optional(v.id("ticketSubcategories")),
requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
companyId: v.optional(v.id("companies")),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch
@ -79,6 +97,7 @@ export default defineSchema({
.index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant", ["tenantId"]),
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({
args: {},
@ -51,30 +52,139 @@ export const seedDemo = mutation({
const queueChamados = queuesBySlug.get("chamados");
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");
}
// Ensure users
async function ensureUser(name: string, email: string, role = "AGENT") {
const found = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
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();
if (found) {
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 (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 (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) {
await ctx.db.patch(found._id, updates);
updates.updatedAt = now;
await ctx.db.patch(existing._id, updates);
}
return found._id;
return existing._id;
}
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: role === "CUSTOMER" ? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}` });
return await ctx.db.insert("companies", payload);
}
const adminId = await ensureUser("Administrador", "admin@sistema.dev", "ADMIN");
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 = [
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@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" },
];
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 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 = [
{
@ -146,13 +308,25 @@ export const seedDemo = mutation({
priority: "URGENT",
channel: "EMAIL",
queueId: queue1,
requesterId: eduardaId,
requesterId: joaoAtlasId,
assigneeId: defaultAssigneeId,
companyId: atlasCompanyId,
createdAt: now - 1000 * 60 * 60 * 5,
updatedAt: now - 1000 * 60 * 10,
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", {
tenantId,
@ -163,13 +337,54 @@ export const seedDemo = mutation({
priority: "HIGH",
channel: "WHATSAPP",
queueId: queue2,
requesterId: clienteDemoId,
requesterId: ricardoOmniId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
createdAt: now - 1000 * 60 * 60 * 8,
updatedAt: now - 1000 * 60 * 30,
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 type { MutationCtx } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireCustomer, requireStaff, requireUser } from "./rbac";
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> = {
"Suporte N1": "Chamados",
@ -188,11 +219,19 @@ export const list = query({
if (!args.viewerId) {
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
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
.query("tickets")
.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") {
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);
@ -314,13 +358,19 @@ export const list = query({
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
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);
if (!t || t.tenantId !== tenantId) return null;
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
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 queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const queueName = normalizeQueueName(queue);
@ -479,7 +529,7 @@ export const create = mutation({
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")
}
@ -497,7 +547,7 @@ export const create = mutation({
}
initialAssigneeId = assignee._id
initialAssignee = assignee
} else if (role && STAFF_ROLES.has(role)) {
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
initialAssigneeId = actorUser._id
initialAssignee = actorUser
}
@ -515,6 +565,19 @@ export const create = mutation({
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);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
@ -537,6 +600,7 @@ export const create = mutation({
subcategoryId: args.subcategoryId,
requesterId: args.requesterId,
assigneeId: initialAssigneeId,
companyId: requester.companyId ?? undefined,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
@ -550,7 +614,6 @@ export const create = mutation({
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
@ -593,27 +656,35 @@ export const addComment = mutation({
if (!ticket) {
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
if (!author || author.tenantId !== ticket.tenantId) {
if (!author || author.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Autor do comentário inválido")
}
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") {
await requireCustomer(ctx, args.authorId, ticket.tenantId)
await requireCustomer(ctx, args.authorId, ticketDoc.tenantId)
if (args.visibility !== "PUBLIC") {
throw new ConvexError("Clientes só podem registrar comentários públicos")
}
} else if (STAFF_ROLES.has(normalizedRole)) {
await requireStaff(ctx, args.authorId, ticket.tenantId)
await requireTicketStaff(ctx, args.authorId, ticketDoc)
} else {
throw new ConvexError("Autor não possui permissão para comentar")
}
} else {
await requireStaff(ctx, args.authorId, ticket.tenantId)
await requireTicketStaff(ctx, args.authorId, ticketDoc)
}
const now = Date.now();
@ -650,6 +721,7 @@ export const updateComment = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
@ -657,10 +729,10 @@ export const updateComment = mutation({
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const now = Date.now();
@ -698,6 +770,7 @@ export const removeCommentAttachment = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
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")
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
if (ticketDoc.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
await requireTicketStaff(ctx, actorId, ticketDoc)
}
const attachments = comment.attachments ?? [];
@ -751,7 +824,8 @@ export const updateStatus = mutation({
if (!ticket) {
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();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
@ -778,11 +852,15 @@ export const changeAssignee = mutation({
if (!ticket) {
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
if (!assignee || assignee.tenantId !== ticket.tenantId) {
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Responsável inválido")
}
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
await ctx.db.insert("ticketEvents", {
@ -801,9 +879,13 @@ export const changeQueue = mutation({
if (!ticket) {
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
if (!queue || queue.tenantId !== ticket.tenantId) {
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now();
@ -830,13 +912,17 @@ export const updateCategories = mutation({
if (!ticket) {
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 (subcategoryId !== null) {
throw new ConvexError("Subcategoria inválida")
}
if (!ticket.categoryId && !ticket.subcategoryId) {
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
return { status: "unchanged" }
}
const now = Date.now()
@ -864,20 +950,20 @@ export const updateCategories = mutation({
}
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")
}
let subcategoryName: string | null = null
if (subcategoryId !== null) {
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")
}
subcategoryName = subcategory.name
}
if (ticket.categoryId === categoryId && (ticket.subcategoryId ?? null) === subcategoryId) {
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
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])
}
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 {
id String @id @default(cuid())
tenantId String
@ -78,6 +98,7 @@ model User {
role UserRole
timezone String @default("America/Sao_Paulo")
avatarUrl String?
companyId String?
teams TeamMember[]
requestedTickets Ticket[] @relation("TicketRequester")
assignedTickets Ticket[] @relation("TicketAssignee")
@ -85,7 +106,10 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id])
@@index([tenantId, role])
@@index([tenantId, companyId])
}
model Queue {
@ -116,6 +140,7 @@ model Ticket {
requesterId String
assigneeId String?
slaPolicyId String?
companyId String?
dueAt DateTime?
firstResponseAt DateTime?
resolvedAt DateTime?
@ -127,12 +152,14 @@ model Ticket {
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id])
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
company Company? @relation(fields: [companyId], references: [id])
events TicketEvent[]
comments TicketComment[]
@@index([tenantId, status])
@@index([tenantId, queueId])
@@index([tenantId, assigneeId])
@@index([tenantId, companyId])
}
model TicketEvent {

View file

@ -41,7 +41,58 @@ function toDate(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()
for (const user of snapshotUsers) {
@ -50,6 +101,7 @@ async function upsertUsers(snapshotUsers) {
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null
const record = await prisma.user.upsert({
where: { email: normalizedEmail },
@ -58,6 +110,7 @@ async function upsertUsers(snapshotUsers) {
role,
tenantId,
avatarUrl: user.avatarUrl ?? null,
companyId,
},
create: {
email: normalizedEmail,
@ -65,6 +118,7 @@ async function upsertUsers(snapshotUsers) {
role,
tenantId,
avatarUrl: user.avatarUrl ?? null,
companyId,
},
})
@ -80,6 +134,7 @@ async function upsertUsers(snapshotUsers) {
name: staff.name,
role: staff.role,
tenantId,
companyId: null,
},
create: {
email: normalizedEmail,
@ -87,6 +142,7 @@ async function upsertUsers(snapshotUsers) {
role: staff.role,
tenantId,
avatarUrl: null,
companyId: null,
},
})
map.set(normalizedEmail, record.id)
@ -97,7 +153,7 @@ async function upsertUsers(snapshotUsers) {
const removableStaff = await prisma.user.findMany({
where: {
tenantId,
role: { in: ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] },
role: { in: ["ADMIN", "AGENT", "COLLABORATOR"] },
email: {
notIn: Array.from(allowedStaffEmails),
},
@ -157,7 +213,7 @@ async function upsertQueues(snapshotQueues) {
return map
}
async function upsertTickets(snapshotTickets, userMap, queueMap) {
async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
let created = 0
let updated = 0
@ -171,6 +227,15 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
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 assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
@ -196,6 +261,7 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
closedAt: toDate(ticket.closedAt),
createdAt: toDate(ticket.createdAt) ?? new Date(),
updatedAt: toDate(ticket.updatedAt) ?? new Date(),
companyId,
}
let ticketRecord
@ -264,12 +330,17 @@ async function run() {
tenantId,
})
console.log(`Empresas recebidas: ${snapshot.companies.length}`)
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 empresas no Prisma...")
const companyMap = await upsertCompanies(snapshot.companies)
console.log(`Empresas ativas no mapa: ${companyMap.size}`)
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("Sincronizando filas no Prisma...")
@ -277,7 +348,7 @@ async function run() {
console.log(`Filas ativas no mapa: ${queueMap.size}`)
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 atualizados: ${ticketStats.updated}`)
}

View file

@ -31,6 +31,48 @@ const defaultUsers = singleUserFromEnv ?? [
role: "customer",
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",
password: "agent123",

View file

@ -30,12 +30,13 @@ async function main() {
process.exit(1)
}
const [users, queues, tickets] = await Promise.all([
const [users, queues, tickets, companies] = await Promise.all([
prisma.user.findMany({
include: {
teams: {
include: { team: true },
},
company: true,
},
}),
prisma.queue.findMany(),
@ -44,6 +45,7 @@ async function main() {
requester: true,
assignee: true,
queue: true,
company: true,
comments: {
include: {
author: true,
@ -53,6 +55,7 @@ async function main() {
},
orderBy: { createdAt: "asc" },
}),
prisma.company.findMany(),
])
const userSnapshot = users.map((user) => ({
@ -63,6 +66,7 @@ async function main() {
teams: user.teams
.map((membership) => membership.team?.name)
.filter((name) => Boolean(name) && typeof name === "string"),
companySlug: user.company?.slug ?? undefined,
}))
const queueSnapshot = queues.map((queue) => ({
@ -78,6 +82,7 @@ async function main() {
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)
const companySlug = ticket.company?.slug ?? ticket.requester?.company?.slug ?? undefined
return {
reference,
@ -89,6 +94,7 @@ async function main() {
queueSlug: queueSlug ?? undefined,
requesterEmail,
assigneeEmail,
companySlug,
dueAt: toMillis(ticket.dueAt) ?? undefined,
firstResponseAt: toMillis(ticket.firstResponseAt) ?? 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 result = await client.mutation("migrations:importPrismaSnapshot", {
secret,
snapshot: {
tenantId,
companies: companySnapshot,
users: userSnapshot,
queues: queueSnapshot,
tickets: ticketSnapshot,

View file

@ -1,28 +1,6 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { TicketsView } from "@/components/tickets/tickets-view"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import { TicketsPageClient } from "./tickets-page-client"
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>
)
return <TicketsPageClient />
}

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

View file

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