diff --git a/web/agents.md b/web/agents.md index f13814a..435aa56 100644 --- a/web/agents.md +++ b/web/agents.md @@ -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 --- diff --git a/web/convex/migrations.ts b/web/convex/migrations.ts index 5af4719..cb7274a 100644 --- a/web/convex/migrations.ts +++ b/web/convex/migrations.ts @@ -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> + cache: Map>, + companyCache: Map> ) { 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> +) { + 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>() const userCache = new Map>() const queueCache = new Map>() + 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, diff --git a/web/convex/rbac.ts b/web/convex/rbac.ts index d4af701..f729a6f 100644 --- a/web/convex/rbac.ts +++ b/web/convex/rbac.ts @@ -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 +} diff --git a/web/convex/reports.ts b/web/convex/reports.ts index 8a3ece0..596fdc4 100644 --- a/web/convex/reports.ts +++ b/web/convex/reports.ts @@ -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>, +) { + 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>((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(); diff --git a/web/convex/schema.ts b/web/convex/schema.ts index 862369b..a4f54c9 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -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 @@ -78,7 +96,8 @@ export default defineSchema({ .index("by_tenant_status", ["tenantId", "status"]) .index("by_tenant_queue", ["tenantId", "queueId"]) .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"]), ticketComments: defineTable({ diff --git a/web/convex/seed.ts b/web/convex/seed.ts index 65e08c0..d27d6e9 100644 --- a/web/convex/seed.ts +++ b/web/convex/seed.ts @@ -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)) - .first(); - if (found) { - const updates: Record = {}; - if (found.name !== name) updates.name = name; - if ((found.role ?? "AGENT") !== role) updates.role = role; - const desiredAvatar = role === "CUSTOMER" ? found.avatarUrl ?? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}`; - if (found.avatarUrl !== desiredAvatar) updates.avatarUrl = desiredAvatar; - if (Object.keys(updates).length > 0) { - await ctx.db.patch(found._id, updates); - } - return found._id; - } - return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: role === "CUSTOMER" ? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}` }); + function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); } - 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> { + 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 = {}; + 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> { + 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 = {}; + 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>(); + 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 }, + }); }, }); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 6d0dc03..d84ed87 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -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 | 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 = { "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 = { @@ -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" } } diff --git a/web/prisma/migrations/20251006235816_add_companies/migration.sql b/web/prisma/migrations/20251006235816_add_companies/migration.sql new file mode 100644 index 0000000..0e880a3 --- /dev/null +++ b/web/prisma/migrations/20251006235816_add_companies/migration.sql @@ -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"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 4aa7b5c..3e8bb64 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -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 { diff --git a/web/scripts/import-convex-to-prisma.mjs b/web/scripts/import-convex-to-prisma.mjs index a4ee1e3..46ecb53 100644 --- a/web/scripts/import-convex-to-prisma.mjs +++ b/web/scripts/import-convex-to-prisma.mjs @@ -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}`) } diff --git a/web/scripts/seed-auth.mjs b/web/scripts/seed-auth.mjs index 5002869..7c07245 100644 --- a/web/scripts/seed-auth.mjs +++ b/web/scripts/seed-auth.mjs @@ -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", diff --git a/web/scripts/sync-prisma-to-convex.mjs b/web/scripts/sync-prisma-to-convex.mjs index 4b3fd05..6397414 100644 --- a/web/scripts/sync-prisma-to-convex.mjs +++ b/web/scripts/sync-prisma-to-convex.mjs @@ -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, diff --git a/web/src/app/tickets/page.tsx b/web/src/app/tickets/page.tsx index 7fafd1a..3ff2888 100644 --- a/web/src/app/tickets/page.tsx +++ b/web/src/app/tickets/page.tsx @@ -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" - -export default function TicketsPage() { - return ( - Exportar CSV} - primaryAction={} - /> - } - > -
-
- -
- -
-
- ) -} +import { TicketsPageClient } from "./tickets-page-client" + +export default function TicketsPage() { + return +} diff --git a/web/src/app/tickets/tickets-page-client.tsx b/web/src/app/tickets/tickets-page-client.tsx new file mode 100644 index 0000000..a2e0927 --- /dev/null +++ b/web/src/app/tickets/tickets-page-client.tsx @@ -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 ( + Exportar CSV} + primaryAction={} + /> + } + > +
+
+ +
+ +
+
+ ) +} diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index 4cd9512..b12ef34 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -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}
Visibilidade: - { + if (isManager) return + setVisibility(value as "PUBLIC" | "INTERNAL") + }} + disabled={isManager} + > Pública - Interna + {!isManager ? Interna : null}
diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index eeb6730..629c8e3 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -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) { Categoria primária {editing ? ( { + if (isManager) return if (value === EMPTY_SUBCATEGORY_VALUE) { setCategorySelection((prev) => ({ ...prev, subcategoryId: "" })) return @@ -407,9 +415,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { Fila {editing ? ( { 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"> })