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:
parent
409cbea7b9
commit
854887f499
16 changed files with 955 additions and 126 deletions
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
||||
|
|
|
|||
121
web/prisma/migrations/20251006235816_add_companies/migration.sql
Normal file
121
web/prisma/migrations/20251006235816_add_companies/migration.sql
Normal 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");
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
||||
|
|
|
|||
52
web/src/app/tickets/tickets-page-client.tsx
Normal file
52
web/src/app/tickets/tickets-page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"> })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue