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
|
# 🧩 Permissões e acessos
|
||||||
|
|
||||||
- [ ] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
|
- [x] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
|
||||||
- [ ] Ver todos os chamados da sua empresa
|
- [x] Ver todos os chamados da sua empresa
|
||||||
- [ ] Acessar relatórios e dashboards resumidos
|
- [x] Acessar relatórios e dashboards resumidos
|
||||||
- [ ] Exportar relatórios em PDF ou CSV
|
- [ ] Exportar relatórios em PDF ou CSV
|
||||||
- [ ] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
|
- [x] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||||
|
|
||||||
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
|
||||||
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
|
||||||
|
|
||||||
function normalizeEmail(value: string) {
|
function normalizeEmail(value: string) {
|
||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
|
|
@ -18,6 +19,7 @@ type ImportedUser = {
|
||||||
role?: string | null
|
role?: string | null
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
teams?: string[] | null
|
teams?: string[] | null
|
||||||
|
companySlug?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImportedQueue = {
|
type ImportedQueue = {
|
||||||
|
|
@ -25,11 +27,22 @@ type ImportedQueue = {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImportedCompany = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
cnpj?: string | null
|
||||||
|
domain?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
description?: string | null
|
||||||
|
address?: string | null
|
||||||
|
createdAt?: number | null
|
||||||
|
updatedAt?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRole(role: string | null | undefined) {
|
function normalizeRole(role: string | null | undefined) {
|
||||||
if (!role) return "AGENT"
|
if (!role) return "AGENT"
|
||||||
const normalized = role.toUpperCase()
|
const normalized = role.toUpperCase()
|
||||||
if (STAFF_ROLES.has(normalized)) return normalized
|
if (VALID_ROLES.has(normalized)) return normalized
|
||||||
if (normalized === "CUSTOMER") return "CUSTOMER"
|
|
||||||
return "AGENT"
|
return "AGENT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +70,8 @@ async function ensureUser(
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
data: ImportedUser,
|
data: ImportedUser,
|
||||||
cache: Map<string, Id<"users">>
|
cache: Map<string, Id<"users">>,
|
||||||
|
companyCache: Map<string, Id<"companies">>
|
||||||
) {
|
) {
|
||||||
if (cache.has(data.email)) {
|
if (cache.has(data.email)) {
|
||||||
return cache.get(data.email)!
|
return cache.get(data.email)!
|
||||||
|
|
@ -68,13 +82,15 @@ async function ensureUser(
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
const role = normalizeRole(data.role)
|
const role = normalizeRole(data.role)
|
||||||
|
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
|
||||||
const record = existing
|
const record = existing
|
||||||
? (() => {
|
? (() => {
|
||||||
const needsPatch =
|
const needsPatch =
|
||||||
existing.name !== data.name ||
|
existing.name !== data.name ||
|
||||||
existing.role !== role ||
|
existing.role !== role ||
|
||||||
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
|
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
|
||||||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? [])
|
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
|
||||||
|
(existing.companyId ?? undefined) !== companyId
|
||||||
if (needsPatch) {
|
if (needsPatch) {
|
||||||
return ctx.db.patch(existing._id, {
|
return ctx.db.patch(existing._id, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
|
@ -82,6 +98,7 @@ async function ensureUser(
|
||||||
avatarUrl: data.avatarUrl ?? undefined,
|
avatarUrl: data.avatarUrl ?? undefined,
|
||||||
teams: data.teams ?? undefined,
|
teams: data.teams ?? undefined,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
companyId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|
@ -93,6 +110,7 @@ async function ensureUser(
|
||||||
role,
|
role,
|
||||||
avatarUrl: data.avatarUrl ?? undefined,
|
avatarUrl: data.avatarUrl ?? undefined,
|
||||||
teams: data.teams ?? undefined,
|
teams: data.teams ?? undefined,
|
||||||
|
companyId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const id = existing ? existing._id : ((await record) as Id<"users">)
|
const id = existing ? existing._id : ((await record) as Id<"users">)
|
||||||
|
|
@ -144,6 +162,64 @@ async function ensureQueue(
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureCompany(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
tenantId: string,
|
||||||
|
data: ImportedCompany,
|
||||||
|
cache: Map<string, Id<"companies">>
|
||||||
|
) {
|
||||||
|
const slug = data.slug || slugify(data.name)
|
||||||
|
if (cache.has(slug)) {
|
||||||
|
return cache.get(slug)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("companies")
|
||||||
|
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const payload = pruneUndefined({
|
||||||
|
tenantId,
|
||||||
|
name: data.name,
|
||||||
|
slug,
|
||||||
|
cnpj: data.cnpj ?? undefined,
|
||||||
|
domain: data.domain ?? undefined,
|
||||||
|
phone: data.phone ?? undefined,
|
||||||
|
description: data.description ?? undefined,
|
||||||
|
address: data.address ?? undefined,
|
||||||
|
createdAt: data.createdAt ?? Date.now(),
|
||||||
|
updatedAt: data.updatedAt ?? Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
let id: Id<"companies">
|
||||||
|
if (existing) {
|
||||||
|
const needsPatch =
|
||||||
|
existing.name !== payload.name ||
|
||||||
|
existing.cnpj !== (payload.cnpj ?? undefined) ||
|
||||||
|
existing.domain !== (payload.domain ?? undefined) ||
|
||||||
|
existing.phone !== (payload.phone ?? undefined) ||
|
||||||
|
existing.description !== (payload.description ?? undefined) ||
|
||||||
|
existing.address !== (payload.address ?? undefined)
|
||||||
|
if (needsPatch) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
name: payload.name,
|
||||||
|
cnpj: payload.cnpj,
|
||||||
|
domain: payload.domain,
|
||||||
|
phone: payload.phone,
|
||||||
|
description: payload.description,
|
||||||
|
address: payload.address,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
id = existing._id
|
||||||
|
} else {
|
||||||
|
id = await ctx.db.insert("companies", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(slug, id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
|
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
|
||||||
return ctx.db
|
return ctx.db
|
||||||
.query("users")
|
.query("users")
|
||||||
|
|
@ -158,6 +234,13 @@ async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTenantCompanies(ctx: QueryCtx, tenantId: string) {
|
||||||
|
return ctx.db
|
||||||
|
.query("companies")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
export const exportTenantSnapshot = query({
|
export const exportTenantSnapshot = query({
|
||||||
args: {
|
args: {
|
||||||
secret: v.string(),
|
secret: v.string(),
|
||||||
|
|
@ -168,10 +251,15 @@ export const exportTenantSnapshot = query({
|
||||||
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
|
||||||
}
|
}
|
||||||
|
|
||||||
const [users, queues] = await Promise.all([getTenantUsers(ctx, tenantId), getTenantQueues(ctx, tenantId)])
|
const [users, queues, companies] = await Promise.all([
|
||||||
|
getTenantUsers(ctx, tenantId),
|
||||||
|
getTenantQueues(ctx, tenantId),
|
||||||
|
getTenantCompanies(ctx, tenantId),
|
||||||
|
])
|
||||||
|
|
||||||
const userMap = new Map(users.map((user) => [user._id, user]))
|
const userMap = new Map(users.map((user) => [user._id, user]))
|
||||||
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
|
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
|
||||||
|
const companyMap = new Map(companies.map((company) => [company._id, company]))
|
||||||
|
|
||||||
const tickets = await ctx.db
|
const tickets = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -194,6 +282,11 @@ export const exportTenantSnapshot = query({
|
||||||
const requester = userMap.get(ticket.requesterId)
|
const requester = userMap.get(ticket.requesterId)
|
||||||
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
|
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
|
||||||
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
|
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
|
||||||
|
const company = ticket.companyId
|
||||||
|
? companyMap.get(ticket.companyId)
|
||||||
|
: requester?.companyId
|
||||||
|
? companyMap.get(requester.companyId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (!requester) {
|
if (!requester) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -209,6 +302,7 @@ export const exportTenantSnapshot = query({
|
||||||
queueSlug: queue?.slug ?? undefined,
|
queueSlug: queue?.slug ?? undefined,
|
||||||
requesterEmail: requester.email,
|
requesterEmail: requester.email,
|
||||||
assigneeEmail: assignee?.email ?? undefined,
|
assigneeEmail: assignee?.email ?? undefined,
|
||||||
|
companySlug: company?.slug ?? undefined,
|
||||||
dueAt: ticket.dueAt ?? undefined,
|
dueAt: ticket.dueAt ?? undefined,
|
||||||
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
||||||
resolvedAt: ticket.resolvedAt ?? undefined,
|
resolvedAt: ticket.resolvedAt ?? undefined,
|
||||||
|
|
@ -247,12 +341,24 @@ export const exportTenantSnapshot = query({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
companies: companies.map((company) => ({
|
||||||
|
slug: company.slug,
|
||||||
|
name: company.name,
|
||||||
|
cnpj: company.cnpj ?? null,
|
||||||
|
domain: company.domain ?? null,
|
||||||
|
phone: company.phone ?? null,
|
||||||
|
description: company.description ?? null,
|
||||||
|
address: company.address ?? null,
|
||||||
|
createdAt: company.createdAt,
|
||||||
|
updatedAt: company.updatedAt,
|
||||||
|
})),
|
||||||
users: users.map((user) => ({
|
users: users.map((user) => ({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role ?? null,
|
role: user.role ?? null,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
teams: user.teams ?? [],
|
teams: user.teams ?? [],
|
||||||
|
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
|
||||||
})),
|
})),
|
||||||
queues: queues.map((queue) => ({
|
queues: queues.map((queue) => ({
|
||||||
name: queue.name,
|
name: queue.name,
|
||||||
|
|
@ -268,6 +374,19 @@ export const importPrismaSnapshot = mutation({
|
||||||
secret: v.string(),
|
secret: v.string(),
|
||||||
snapshot: v.object({
|
snapshot: v.object({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
companies: v.array(
|
||||||
|
v.object({
|
||||||
|
slug: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
cnpj: v.optional(v.string()),
|
||||||
|
domain: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
address: v.optional(v.string()),
|
||||||
|
createdAt: v.optional(v.number()),
|
||||||
|
updatedAt: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
),
|
||||||
users: v.array(
|
users: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
email: v.string(),
|
email: v.string(),
|
||||||
|
|
@ -275,6 +394,7 @@ export const importPrismaSnapshot = mutation({
|
||||||
role: v.optional(v.string()),
|
role: v.optional(v.string()),
|
||||||
avatarUrl: v.optional(v.string()),
|
avatarUrl: v.optional(v.string()),
|
||||||
teams: v.optional(v.array(v.string())),
|
teams: v.optional(v.array(v.string())),
|
||||||
|
companySlug: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
queues: v.array(
|
queues: v.array(
|
||||||
|
|
@ -294,6 +414,7 @@ export const importPrismaSnapshot = mutation({
|
||||||
queueSlug: v.optional(v.string()),
|
queueSlug: v.optional(v.string()),
|
||||||
requesterEmail: v.string(),
|
requesterEmail: v.string(),
|
||||||
assigneeEmail: v.optional(v.string()),
|
assigneeEmail: v.optional(v.string()),
|
||||||
|
companySlug: v.optional(v.string()),
|
||||||
dueAt: v.optional(v.number()),
|
dueAt: v.optional(v.number()),
|
||||||
firstResponseAt: v.optional(v.number()),
|
firstResponseAt: v.optional(v.number()),
|
||||||
resolvedAt: v.optional(v.number()),
|
resolvedAt: v.optional(v.number()),
|
||||||
|
|
@ -329,11 +450,16 @@ export const importPrismaSnapshot = mutation({
|
||||||
throw new ConvexError("Segredo inválido para sincronização")
|
throw new ConvexError("Segredo inválido para sincronização")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyCache = new Map<string, Id<"companies">>()
|
||||||
const userCache = new Map<string, Id<"users">>()
|
const userCache = new Map<string, Id<"users">>()
|
||||||
const queueCache = new Map<string, Id<"queues">>()
|
const queueCache = new Map<string, Id<"queues">>()
|
||||||
|
|
||||||
|
for (const company of snapshot.companies) {
|
||||||
|
await ensureCompany(ctx, snapshot.tenantId, company, companyCache)
|
||||||
|
}
|
||||||
|
|
||||||
for (const user of snapshot.users) {
|
for (const user of snapshot.users) {
|
||||||
await ensureUser(ctx, snapshot.tenantId, user, userCache)
|
await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const queue of snapshot.queues) {
|
for (const queue of snapshot.queues) {
|
||||||
|
|
@ -342,7 +468,7 @@ export const importPrismaSnapshot = mutation({
|
||||||
|
|
||||||
const snapshotStaffEmails = new Set(
|
const snapshotStaffEmails = new Set(
|
||||||
snapshot.users
|
snapshot.users
|
||||||
.filter((user) => normalizeRole(user.role ?? null) !== "CUSTOMER")
|
.filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
|
||||||
.map((user) => normalizeEmail(user.email))
|
.map((user) => normalizeEmail(user.email))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -353,7 +479,7 @@ export const importPrismaSnapshot = mutation({
|
||||||
|
|
||||||
for (const user of existingTenantUsers) {
|
for (const user of existingTenantUsers) {
|
||||||
const role = normalizeRole(user.role ?? null)
|
const role = normalizeRole(user.role ?? null)
|
||||||
if (STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
|
if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
|
||||||
await ctx.db.delete(user._id)
|
await ctx.db.delete(user._id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +496,8 @@ export const importPrismaSnapshot = mutation({
|
||||||
email: ticket.requesterEmail,
|
email: ticket.requesterEmail,
|
||||||
name: ticket.requesterEmail,
|
name: ticket.requesterEmail,
|
||||||
},
|
},
|
||||||
userCache
|
userCache,
|
||||||
|
companyCache
|
||||||
)
|
)
|
||||||
const assigneeId = ticket.assigneeEmail
|
const assigneeId = ticket.assigneeEmail
|
||||||
? await ensureUser(
|
? await ensureUser(
|
||||||
|
|
@ -380,11 +507,13 @@ export const importPrismaSnapshot = mutation({
|
||||||
email: ticket.assigneeEmail,
|
email: ticket.assigneeEmail,
|
||||||
name: ticket.assigneeEmail,
|
name: ticket.assigneeEmail,
|
||||||
},
|
},
|
||||||
userCache
|
userCache,
|
||||||
|
companyCache
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
|
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
|
||||||
|
const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined
|
||||||
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -406,6 +535,7 @@ export const importPrismaSnapshot = mutation({
|
||||||
assigneeId: assigneeId as Id<"users"> | undefined,
|
assigneeId: assigneeId as Id<"users"> | undefined,
|
||||||
working: false,
|
working: false,
|
||||||
slaPolicyId: undefined,
|
slaPolicyId: undefined,
|
||||||
|
companyId: companyId as Id<"companies"> | undefined,
|
||||||
dueAt: ticket.dueAt ?? undefined,
|
dueAt: ticket.dueAt ?? undefined,
|
||||||
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
firstResponseAt: ticket.firstResponseAt ?? undefined,
|
||||||
resolvedAt: ticket.resolvedAt ?? undefined,
|
resolvedAt: ticket.resolvedAt ?? undefined,
|
||||||
|
|
@ -452,7 +582,8 @@ export const importPrismaSnapshot = mutation({
|
||||||
email: comment.authorEmail,
|
email: comment.authorEmail,
|
||||||
name: comment.authorEmail,
|
name: comment.authorEmail,
|
||||||
},
|
},
|
||||||
userCache
|
userCache,
|
||||||
|
companyCache
|
||||||
)
|
)
|
||||||
await ctx.db.insert("ticketComments", {
|
await ctx.db.insert("ticketComments", {
|
||||||
ticketId,
|
ticketId,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||||
const CUSTOMER_ROLE = "CUSTOMER"
|
const CUSTOMER_ROLE = "CUSTOMER"
|
||||||
|
const MANAGER_ROLE = "MANAGER"
|
||||||
|
|
||||||
type Ctx = QueryCtx | MutationCtx
|
type Ctx = QueryCtx | MutationCtx
|
||||||
|
|
||||||
|
|
@ -51,3 +52,30 @@ export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?:
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||||
|
const result = await requireUser(ctx, userId, tenantId)
|
||||||
|
if (result.role !== MANAGER_ROLE) {
|
||||||
|
throw new ConvexError("Apenas gestores da empresa podem executar esta ação")
|
||||||
|
}
|
||||||
|
if (!result.user.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireCompanyAssociation(
|
||||||
|
ctx: Ctx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
companyId: Id<"companies">,
|
||||||
|
tenantId?: string,
|
||||||
|
) {
|
||||||
|
const result = await requireUser(ctx, userId, tenantId)
|
||||||
|
if (!result.user.companyId) {
|
||||||
|
throw new ConvexError("Usuário não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
if (result.user.companyId !== companyId) {
|
||||||
|
throw new ConvexError("Usuário não pertence a esta empresa")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { query } from "./_generated/server";
|
import { query } from "./_generated/server";
|
||||||
import type { QueryCtx } from "./_generated/server";
|
import type { QueryCtx } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireStaff } from "./rbac";
|
import { requireStaff } from "./rbac";
|
||||||
|
|
@ -42,6 +42,25 @@ async function fetchTickets(ctx: QueryCtx, tenantId: string) {
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchScopedTickets(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
tenantId: string,
|
||||||
|
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||||
|
) {
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
if (!viewer.user.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada");
|
||||||
|
}
|
||||||
|
return ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_company", (q) =>
|
||||||
|
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!)
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
return fetchTickets(ctx, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||||
return ctx.db
|
return ctx.db
|
||||||
.query("queues")
|
.query("queues")
|
||||||
|
|
@ -92,8 +111,8 @@ function formatDateKey(timestamp: number) {
|
||||||
export const slaOverview = query({
|
export const slaOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchTickets(ctx, tenantId);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
const queues = await fetchQueues(ctx, tenantId);
|
const queues = await fetchQueues(ctx, tenantId);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -140,8 +159,8 @@ export const slaOverview = query({
|
||||||
export const csatOverview = query({
|
export const csatOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchTickets(ctx, tenantId);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
const surveys = await collectCsatSurveys(ctx, tickets);
|
const surveys = await collectCsatSurveys(ctx, tickets);
|
||||||
|
|
||||||
const averageScore = average(surveys.map((item) => item.score));
|
const averageScore = average(surveys.map((item) => item.score));
|
||||||
|
|
@ -171,8 +190,8 @@ export const csatOverview = query({
|
||||||
export const backlogOverview = query({
|
export const backlogOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchTickets(ctx, tenantId);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
|
|
||||||
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
|
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
|
||||||
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
|
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
|
||||||
|
|
@ -218,8 +237,8 @@ export const backlogOverview = query({
|
||||||
export const dashboardOverview = query({
|
export const dashboardOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchTickets(ctx, tenantId);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const lastDayStart = now - ONE_DAY_MS;
|
const lastDayStart = now - ONE_DAY_MS;
|
||||||
|
|
@ -294,8 +313,8 @@ export const ticketsByChannel = query({
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||||
await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchTickets(ctx, tenantId);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,26 @@ export default defineSchema({
|
||||||
role: v.optional(v.string()),
|
role: v.optional(v.string()),
|
||||||
avatarUrl: v.optional(v.string()),
|
avatarUrl: v.optional(v.string()),
|
||||||
teams: v.optional(v.array(v.string())),
|
teams: v.optional(v.array(v.string())),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
})
|
})
|
||||||
.index("by_tenant_email", ["tenantId", "email"])
|
.index("by_tenant_email", ["tenantId", "email"])
|
||||||
.index("by_tenant_role", ["tenantId", "role"])
|
.index("by_tenant_role", ["tenantId", "role"])
|
||||||
|
.index("by_tenant", ["tenantId"])
|
||||||
|
.index("by_tenant_company", ["tenantId", "companyId"]),
|
||||||
|
|
||||||
|
companies: defineTable({
|
||||||
|
tenantId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
slug: v.string(),
|
||||||
|
cnpj: v.optional(v.string()),
|
||||||
|
domain: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
address: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||||
.index("by_tenant", ["tenantId"]),
|
.index("by_tenant", ["tenantId"]),
|
||||||
|
|
||||||
queues: defineTable({
|
queues: defineTable({
|
||||||
|
|
@ -51,6 +68,7 @@ export default defineSchema({
|
||||||
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
subcategoryId: v.optional(v.id("ticketSubcategories")),
|
||||||
requesterId: v.id("users"),
|
requesterId: v.id("users"),
|
||||||
assigneeId: v.optional(v.id("users")),
|
assigneeId: v.optional(v.id("users")),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
working: v.optional(v.boolean()),
|
working: v.optional(v.boolean()),
|
||||||
slaPolicyId: v.optional(v.id("slaPolicies")),
|
slaPolicyId: v.optional(v.id("slaPolicies")),
|
||||||
dueAt: v.optional(v.number()), // ms since epoch
|
dueAt: v.optional(v.number()), // ms since epoch
|
||||||
|
|
@ -79,6 +97,7 @@ export default defineSchema({
|
||||||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||||
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
|
||||||
.index("by_tenant_reference", ["tenantId", "reference"])
|
.index("by_tenant_reference", ["tenantId", "reference"])
|
||||||
|
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||||
.index("by_tenant", ["tenantId"]),
|
.index("by_tenant", ["tenantId"]),
|
||||||
|
|
||||||
ticketComments: defineTable({
|
ticketComments: defineTable({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { mutation } from "./_generated/server";
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
import { mutation } from "./_generated/server"
|
||||||
|
|
||||||
export const seedDemo = mutation({
|
export const seedDemo = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
|
|
@ -51,30 +52,139 @@ export const seedDemo = mutation({
|
||||||
|
|
||||||
const queueChamados = queuesBySlug.get("chamados");
|
const queueChamados = queuesBySlug.get("chamados");
|
||||||
const queueLaboratorio = queuesBySlug.get("laboratorio");
|
const queueLaboratorio = queuesBySlug.get("laboratorio");
|
||||||
if (!queueChamados || !queueLaboratorio) {
|
const queueVisitas = queuesBySlug.get("visitas");
|
||||||
|
if (!queueChamados || !queueLaboratorio || !queueVisitas) {
|
||||||
throw new Error("Filas padrão não foram inicializadas");
|
throw new Error("Filas padrão não foram inicializadas");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure users
|
// Ensure users
|
||||||
async function ensureUser(name: string, email: string, role = "AGENT") {
|
function slugify(value: string) {
|
||||||
const found = await ctx.db
|
return value
|
||||||
.query("users")
|
.normalize("NFD")
|
||||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.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();
|
.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> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (found.name !== name) updates.name = name;
|
if (existing.name !== payload.name) updates.name = payload.name;
|
||||||
if ((found.role ?? "AGENT") !== role) updates.role = role;
|
if (existing.cnpj !== payload.cnpj) updates.cnpj = payload.cnpj;
|
||||||
const desiredAvatar = role === "CUSTOMER" ? found.avatarUrl ?? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}`;
|
if (existing.domain !== payload.domain) updates.domain = payload.domain;
|
||||||
if (found.avatarUrl !== desiredAvatar) updates.avatarUrl = desiredAvatar;
|
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) {
|
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 = [
|
const staffRoster = [
|
||||||
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
|
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
|
||||||
{ name: "George Araujo", email: "george.araujo@rever.com.br" },
|
{ name: "George Araujo", email: "george.araujo@rever.com.br" },
|
||||||
|
|
@ -86,10 +196,62 @@ export const seedDemo = mutation({
|
||||||
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
|
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const staffIds = await Promise.all(staffRoster.map((staff) => ensureUser(staff.name, staff.email)));
|
const staffIds = await Promise.all(
|
||||||
|
staffRoster.map((staff) => ensureUser({ name: staff.name, email: staff.email, role: "AGENT" })),
|
||||||
|
);
|
||||||
const defaultAssigneeId = staffIds[0] ?? adminId;
|
const defaultAssigneeId = staffIds[0] ?? adminId;
|
||||||
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
|
|
||||||
const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER");
|
const atlasCompanyId = companyIds.get("atlas-engenharia");
|
||||||
|
const omniCompanyId = companyIds.get("omni-saude");
|
||||||
|
if (!atlasCompanyId || !omniCompanyId) {
|
||||||
|
throw new Error("Empresas padrão não foram inicializadas");
|
||||||
|
}
|
||||||
|
|
||||||
|
const atlasManagerId = await ensureUser({
|
||||||
|
name: "Mariana Andrade",
|
||||||
|
email: "mariana.andrade@atlasengenharia.com.br",
|
||||||
|
role: "MANAGER",
|
||||||
|
companyId: atlasCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const joaoAtlasId = await ensureUser({
|
||||||
|
name: "João Pedro Ramos",
|
||||||
|
email: "joao.ramos@atlasengenharia.com.br",
|
||||||
|
role: "CUSTOMER",
|
||||||
|
companyId: atlasCompanyId,
|
||||||
|
});
|
||||||
|
await ensureUser({
|
||||||
|
name: "Aline Rezende",
|
||||||
|
email: "aline.rezende@atlasengenharia.com.br",
|
||||||
|
role: "CUSTOMER",
|
||||||
|
companyId: atlasCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const omniManagerId = await ensureUser({
|
||||||
|
name: "Fernanda Lima",
|
||||||
|
email: "fernanda.lima@omnisaude.com.br",
|
||||||
|
role: "MANAGER",
|
||||||
|
companyId: omniCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ricardoOmniId = await ensureUser({
|
||||||
|
name: "Ricardo Matos",
|
||||||
|
email: "ricardo.matos@omnisaude.com.br",
|
||||||
|
role: "CUSTOMER",
|
||||||
|
companyId: omniCompanyId,
|
||||||
|
});
|
||||||
|
await ensureUser({
|
||||||
|
name: "Luciana Prado",
|
||||||
|
email: "luciana.prado@omnisaude.com.br",
|
||||||
|
role: "CUSTOMER",
|
||||||
|
companyId: omniCompanyId,
|
||||||
|
});
|
||||||
|
const clienteDemoId = await ensureUser({
|
||||||
|
name: "Cliente Demo",
|
||||||
|
email: "cliente.demo@sistema.dev",
|
||||||
|
role: "CUSTOMER",
|
||||||
|
companyId: omniCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const templateDefinitions = [
|
const templateDefinitions = [
|
||||||
{
|
{
|
||||||
|
|
@ -146,13 +308,25 @@ export const seedDemo = mutation({
|
||||||
priority: "URGENT",
|
priority: "URGENT",
|
||||||
channel: "EMAIL",
|
channel: "EMAIL",
|
||||||
queueId: queue1,
|
queueId: queue1,
|
||||||
requesterId: eduardaId,
|
requesterId: joaoAtlasId,
|
||||||
assigneeId: defaultAssigneeId,
|
assigneeId: defaultAssigneeId,
|
||||||
|
companyId: atlasCompanyId,
|
||||||
createdAt: now - 1000 * 60 * 60 * 5,
|
createdAt: now - 1000 * 60 * 60 * 5,
|
||||||
updatedAt: now - 1000 * 60 * 10,
|
updatedAt: now - 1000 * 60 * 10,
|
||||||
tags: ["portal", "cliente"],
|
tags: ["portal", "cliente"],
|
||||||
});
|
});
|
||||||
await ctx.db.insert("ticketEvents", { ticketId: t1, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 5, payload: {} });
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t1,
|
||||||
|
type: "CREATED",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 5,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t1,
|
||||||
|
type: "MANAGER_NOTIFIED",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 4,
|
||||||
|
payload: { managerUserId: atlasManagerId },
|
||||||
|
});
|
||||||
|
|
||||||
const t2 = await ctx.db.insert("tickets", {
|
const t2 = await ctx.db.insert("tickets", {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -163,13 +337,54 @@ export const seedDemo = mutation({
|
||||||
priority: "HIGH",
|
priority: "HIGH",
|
||||||
channel: "WHATSAPP",
|
channel: "WHATSAPP",
|
||||||
queueId: queue2,
|
queueId: queue2,
|
||||||
requesterId: clienteDemoId,
|
requesterId: ricardoOmniId,
|
||||||
assigneeId: defaultAssigneeId,
|
assigneeId: defaultAssigneeId,
|
||||||
|
companyId: omniCompanyId,
|
||||||
createdAt: now - 1000 * 60 * 60 * 8,
|
createdAt: now - 1000 * 60 * 60 * 8,
|
||||||
updatedAt: now - 1000 * 60 * 30,
|
updatedAt: now - 1000 * 60 * 30,
|
||||||
tags: ["Integração", "erp"],
|
tags: ["Integração", "erp"],
|
||||||
});
|
});
|
||||||
await ctx.db.insert("ticketEvents", { ticketId: t2, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 8, payload: {} });
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t2,
|
||||||
|
type: "CREATED",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 8,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t2,
|
||||||
|
type: "MANAGER_NOTIFIED",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 7,
|
||||||
|
payload: { managerUserId: omniManagerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const t3 = await ctx.db.insert("tickets", {
|
||||||
|
tenantId,
|
||||||
|
reference: ++ref,
|
||||||
|
subject: "Visita técnica para instalação de roteadores",
|
||||||
|
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
|
||||||
|
status: "OPEN",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
channel: "PHONE",
|
||||||
|
queueId: queueVisitas._id,
|
||||||
|
requesterId: clienteDemoId,
|
||||||
|
assigneeId: defaultAssigneeId,
|
||||||
|
companyId: omniCompanyId,
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 3,
|
||||||
|
updatedAt: now - 1000 * 60 * 20,
|
||||||
|
tags: ["visita", "infraestrutura"],
|
||||||
|
});
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t3,
|
||||||
|
type: "CREATED",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 3,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t3,
|
||||||
|
type: "VISIT_SCHEDULED",
|
||||||
|
createdAt: now - 1000 * 60 * 15,
|
||||||
|
payload: { scheduledFor: now + 1000 * 60 * 60 * 24 },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,42 @@
|
||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import type { MutationCtx } from "./_generated/server";
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc } from "./_generated/dataModel";
|
import { Id, type Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||||
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
||||||
|
|
||||||
|
async function ensureManagerTicketAccess(
|
||||||
|
ctx: MutationCtx | QueryCtx,
|
||||||
|
manager: Doc<"users">,
|
||||||
|
ticket: Doc<"tickets">,
|
||||||
|
): Promise<Doc<"users"> | null> {
|
||||||
|
if (!manager.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
if (ticket.companyId && ticket.companyId === manager.companyId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const requester = await ctx.db.get(ticket.requesterId)
|
||||||
|
if (!requester || requester.companyId !== manager.companyId) {
|
||||||
|
throw new ConvexError("Acesso restrito à empresa")
|
||||||
|
}
|
||||||
|
return requester as Doc<"users">
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireTicketStaff(
|
||||||
|
ctx: MutationCtx | QueryCtx,
|
||||||
|
actorId: Id<"users">,
|
||||||
|
ticket: Doc<"tickets">
|
||||||
|
) {
|
||||||
|
const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
await ensureManagerTicketAccess(ctx, viewer.user, ticket)
|
||||||
|
}
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
|
|
@ -188,11 +219,19 @@ export const list = query({
|
||||||
if (!args.viewerId) {
|
if (!args.viewerId) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const { role } = await requireUser(ctx, args.viewerId, args.tenantId)
|
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
|
||||||
|
|
||||||
// Choose best index based on provided args for efficiency
|
// Choose best index based on provided args for efficiency
|
||||||
let base: Doc<"tickets">[] = [];
|
let base: Doc<"tickets">[] = [];
|
||||||
if (args.status) {
|
if (role === "MANAGER") {
|
||||||
|
if (!user.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
base = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
||||||
|
.collect();
|
||||||
|
} else if (args.status) {
|
||||||
base = await ctx.db
|
base = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
|
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
|
||||||
|
|
@ -212,6 +251,11 @@ export const list = query({
|
||||||
|
|
||||||
if (role === "CUSTOMER") {
|
if (role === "CUSTOMER") {
|
||||||
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
||||||
|
} else if (role === "MANAGER") {
|
||||||
|
if (!user.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
filtered = filtered.filter((t) => t.companyId === user.companyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||||
|
|
@ -314,13 +358,19 @@ export const list = query({
|
||||||
export const getById = query({
|
export const getById = query({
|
||||||
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
|
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, id, viewerId }) => {
|
handler: async (ctx, { tenantId, id, viewerId }) => {
|
||||||
const { role } = await requireUser(ctx, viewerId, tenantId)
|
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
||||||
const t = await ctx.db.get(id);
|
const t = await ctx.db.get(id);
|
||||||
if (!t || t.tenantId !== tenantId) return null;
|
if (!t || t.tenantId !== tenantId) return null;
|
||||||
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
||||||
throw new ConvexError("Acesso restrito ao solicitante")
|
throw new ConvexError("Acesso restrito ao solicitante")
|
||||||
}
|
}
|
||||||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
let requester: Doc<"users"> | null = null
|
||||||
|
if (role === "MANAGER") {
|
||||||
|
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
|
||||||
|
}
|
||||||
|
if (!requester) {
|
||||||
|
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
|
||||||
|
}
|
||||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||||
const queueName = normalizeQueueName(queue);
|
const queueName = normalizeQueueName(queue);
|
||||||
|
|
@ -479,7 +529,7 @@ export const create = mutation({
|
||||||
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) {
|
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
|
||||||
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,7 +547,7 @@ export const create = mutation({
|
||||||
}
|
}
|
||||||
initialAssigneeId = assignee._id
|
initialAssigneeId = assignee._id
|
||||||
initialAssignee = assignee
|
initialAssignee = assignee
|
||||||
} else if (role && STAFF_ROLES.has(role)) {
|
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
|
||||||
initialAssigneeId = actorUser._id
|
initialAssigneeId = actorUser._id
|
||||||
initialAssignee = actorUser
|
initialAssignee = actorUser
|
||||||
}
|
}
|
||||||
|
|
@ -515,6 +565,19 @@ export const create = mutation({
|
||||||
throw new ConvexError("Subcategoria inválida");
|
throw new ConvexError("Subcategoria inválida");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
|
||||||
|
if (!requester || requester.tenantId !== args.tenantId) {
|
||||||
|
throw new ConvexError("Solicitante inválido")
|
||||||
|
}
|
||||||
|
if (role === "MANAGER") {
|
||||||
|
if (!actorUser.companyId) {
|
||||||
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
|
}
|
||||||
|
if (requester.companyId !== actorUser.companyId) {
|
||||||
|
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
||||||
// compute next reference (simple monotonic counter per tenant)
|
// compute next reference (simple monotonic counter per tenant)
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
|
|
@ -537,6 +600,7 @@ export const create = mutation({
|
||||||
subcategoryId: args.subcategoryId,
|
subcategoryId: args.subcategoryId,
|
||||||
requesterId: args.requesterId,
|
requesterId: args.requesterId,
|
||||||
assigneeId: initialAssigneeId,
|
assigneeId: initialAssigneeId,
|
||||||
|
companyId: requester.companyId ?? undefined,
|
||||||
working: false,
|
working: false,
|
||||||
activeSessionId: undefined,
|
activeSessionId: undefined,
|
||||||
totalWorkedMs: 0,
|
totalWorkedMs: 0,
|
||||||
|
|
@ -550,7 +614,6 @@ export const create = mutation({
|
||||||
dueAt: undefined,
|
dueAt: undefined,
|
||||||
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||||
});
|
});
|
||||||
const requester = await ctx.db.get(args.requesterId);
|
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: id,
|
ticketId: id,
|
||||||
type: "CREATED",
|
type: "CREATED",
|
||||||
|
|
@ -593,27 +656,35 @@ export const addComment = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
|
||||||
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
|
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
|
||||||
if (!author || author.tenantId !== ticket.tenantId) {
|
if (!author || author.tenantId !== ticketDoc.tenantId) {
|
||||||
throw new ConvexError("Autor do comentário inválido")
|
throw new ConvexError("Autor do comentário inválido")
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
||||||
|
|
||||||
if (ticket.requesterId === args.authorId) {
|
if (normalizedRole === "MANAGER") {
|
||||||
|
await ensureManagerTicketAccess(ctx, author, ticketDoc)
|
||||||
|
if (args.visibility !== "PUBLIC") {
|
||||||
|
throw new ConvexError("Gestores só podem registrar comentários públicos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketDoc.requesterId === args.authorId) {
|
||||||
if (normalizedRole === "CUSTOMER") {
|
if (normalizedRole === "CUSTOMER") {
|
||||||
await requireCustomer(ctx, args.authorId, ticket.tenantId)
|
await requireCustomer(ctx, args.authorId, ticketDoc.tenantId)
|
||||||
if (args.visibility !== "PUBLIC") {
|
if (args.visibility !== "PUBLIC") {
|
||||||
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
||||||
}
|
}
|
||||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
} else if (STAFF_ROLES.has(normalizedRole)) {
|
||||||
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
||||||
} else {
|
} else {
|
||||||
throw new ConvexError("Autor não possui permissão para comentar")
|
throw new ConvexError("Autor não possui permissão para comentar")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -650,6 +721,7 @@ export const updateComment = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
const comment = await ctx.db.get(commentId);
|
const comment = await ctx.db.get(commentId);
|
||||||
if (!comment || comment.ticketId !== ticketId) {
|
if (!comment || comment.ticketId !== ticketId) {
|
||||||
throw new ConvexError("Comentário não encontrado");
|
throw new ConvexError("Comentário não encontrado");
|
||||||
|
|
@ -657,10 +729,10 @@ export const updateComment = mutation({
|
||||||
if (comment.authorId !== actorId) {
|
if (comment.authorId !== actorId) {
|
||||||
throw new ConvexError("Você não tem permissão para editar este comentário");
|
throw new ConvexError("Você não tem permissão para editar este comentário");
|
||||||
}
|
}
|
||||||
if (ticket.requesterId === actorId) {
|
if (ticketDoc.requesterId === actorId) {
|
||||||
await requireCustomer(ctx, actorId, ticket.tenantId)
|
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
||||||
} else {
|
} else {
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -698,6 +770,7 @@ export const removeCommentAttachment = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
const comment = await ctx.db.get(commentId);
|
const comment = await ctx.db.get(commentId);
|
||||||
if (!comment || comment.ticketId !== ticketId) {
|
if (!comment || comment.ticketId !== ticketId) {
|
||||||
throw new ConvexError("Comentário não encontrado");
|
throw new ConvexError("Comentário não encontrado");
|
||||||
|
|
@ -706,10 +779,10 @@ export const removeCommentAttachment = mutation({
|
||||||
throw new ConvexError("Você não pode alterar anexos de outro usuário")
|
throw new ConvexError("Você não pode alterar anexos de outro usuário")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.requesterId === actorId) {
|
if (ticketDoc.requesterId === actorId) {
|
||||||
await requireCustomer(ctx, actorId, ticket.tenantId)
|
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
||||||
} else {
|
} else {
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments = comment.attachments ?? [];
|
const attachments = comment.attachments ?? [];
|
||||||
|
|
@ -751,7 +824,8 @@ export const updateStatus = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
||||||
const statusPt: Record<string, string> = {
|
const statusPt: Record<string, string> = {
|
||||||
|
|
@ -778,11 +852,15 @@ export const changeAssignee = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
||||||
if (!assignee || assignee.tenantId !== ticket.tenantId) {
|
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
|
||||||
throw new ConvexError("Responsável inválido")
|
throw new ConvexError("Responsável inválido")
|
||||||
}
|
}
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
throw new ConvexError("Gestores não podem reatribuir chamados")
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
|
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
|
@ -801,9 +879,13 @@ export const changeQueue = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
throw new ConvexError("Gestores não podem alterar a fila do chamado")
|
||||||
|
}
|
||||||
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
||||||
if (!queue || queue.tenantId !== ticket.tenantId) {
|
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
|
||||||
throw new ConvexError("Fila inválida")
|
throw new ConvexError("Fila inválida")
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -830,13 +912,17 @@ export const updateCategories = mutation({
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
|
||||||
|
}
|
||||||
|
|
||||||
if (categoryId === null) {
|
if (categoryId === null) {
|
||||||
if (subcategoryId !== null) {
|
if (subcategoryId !== null) {
|
||||||
throw new ConvexError("Subcategoria inválida")
|
throw new ConvexError("Subcategoria inválida")
|
||||||
}
|
}
|
||||||
if (!ticket.categoryId && !ticket.subcategoryId) {
|
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
|
||||||
return { status: "unchanged" }
|
return { status: "unchanged" }
|
||||||
}
|
}
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
@ -864,20 +950,20 @@ export const updateCategories = mutation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await ctx.db.get(categoryId)
|
const category = await ctx.db.get(categoryId)
|
||||||
if (!category || category.tenantId !== ticket.tenantId) {
|
if (!category || category.tenantId !== ticketDoc.tenantId) {
|
||||||
throw new ConvexError("Categoria inválida")
|
throw new ConvexError("Categoria inválida")
|
||||||
}
|
}
|
||||||
|
|
||||||
let subcategoryName: string | null = null
|
let subcategoryName: string | null = null
|
||||||
if (subcategoryId !== null) {
|
if (subcategoryId !== null) {
|
||||||
const subcategory = await ctx.db.get(subcategoryId)
|
const subcategory = await ctx.db.get(subcategoryId)
|
||||||
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
|
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
|
||||||
throw new ConvexError("Subcategoria inválida")
|
throw new ConvexError("Subcategoria inválida")
|
||||||
}
|
}
|
||||||
subcategoryName = subcategory.name
|
subcategoryName = subcategory.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.categoryId === categoryId && (ticket.subcategoryId ?? null) === subcategoryId) {
|
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
|
||||||
return { status: "unchanged" }
|
return { status: "unchanged" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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])
|
@@id([teamId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Company {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
slug String
|
||||||
|
cnpj String?
|
||||||
|
domain String?
|
||||||
|
phone String?
|
||||||
|
description String?
|
||||||
|
address String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([tenantId, slug])
|
||||||
|
@@index([tenantId, name])
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
|
|
@ -78,6 +98,7 @@ model User {
|
||||||
role UserRole
|
role UserRole
|
||||||
timezone String @default("America/Sao_Paulo")
|
timezone String @default("America/Sao_Paulo")
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
companyId String?
|
||||||
teams TeamMember[]
|
teams TeamMember[]
|
||||||
requestedTickets Ticket[] @relation("TicketRequester")
|
requestedTickets Ticket[] @relation("TicketRequester")
|
||||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||||
|
|
@ -85,7 +106,10 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
|
|
||||||
@@index([tenantId, role])
|
@@index([tenantId, role])
|
||||||
|
@@index([tenantId, companyId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Queue {
|
model Queue {
|
||||||
|
|
@ -116,6 +140,7 @@ model Ticket {
|
||||||
requesterId String
|
requesterId String
|
||||||
assigneeId String?
|
assigneeId String?
|
||||||
slaPolicyId String?
|
slaPolicyId String?
|
||||||
|
companyId String?
|
||||||
dueAt DateTime?
|
dueAt DateTime?
|
||||||
firstResponseAt DateTime?
|
firstResponseAt DateTime?
|
||||||
resolvedAt DateTime?
|
resolvedAt DateTime?
|
||||||
|
|
@ -127,12 +152,14 @@ model Ticket {
|
||||||
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||||
queue Queue? @relation(fields: [queueId], references: [id])
|
queue Queue? @relation(fields: [queueId], references: [id])
|
||||||
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
|
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
events TicketEvent[]
|
events TicketEvent[]
|
||||||
comments TicketComment[]
|
comments TicketComment[]
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([tenantId, queueId])
|
@@index([tenantId, queueId])
|
||||||
@@index([tenantId, assigneeId])
|
@@index([tenantId, assigneeId])
|
||||||
|
@@index([tenantId, companyId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TicketEvent {
|
model TicketEvent {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,58 @@ function toDate(value) {
|
||||||
return new Date(value)
|
return new Date(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertUsers(snapshotUsers) {
|
function slugify(value) {
|
||||||
|
return value
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertCompanies(snapshotCompanies) {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
for (const company of snapshotCompanies) {
|
||||||
|
const slug = company.slug || slugify(company.name)
|
||||||
|
const record = await prisma.company.upsert({
|
||||||
|
where: {
|
||||||
|
tenantId_slug: {
|
||||||
|
tenantId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: company.name,
|
||||||
|
cnpj: company.cnpj ?? null,
|
||||||
|
domain: company.domain ?? null,
|
||||||
|
phone: company.phone ?? null,
|
||||||
|
description: company.description ?? null,
|
||||||
|
address: company.address ?? null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
name: company.name,
|
||||||
|
slug,
|
||||||
|
cnpj: company.cnpj ?? null,
|
||||||
|
domain: company.domain ?? null,
|
||||||
|
phone: company.phone ?? null,
|
||||||
|
description: company.description ?? null,
|
||||||
|
address: company.address ?? null,
|
||||||
|
createdAt: toDate(company.createdAt) ?? new Date(),
|
||||||
|
updatedAt: toDate(company.updatedAt) ?? new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.set(slug, record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertUsers(snapshotUsers, companyMap) {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
|
|
||||||
for (const user of snapshotUsers) {
|
for (const user of snapshotUsers) {
|
||||||
|
|
@ -50,6 +101,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
|
|
||||||
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
|
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
|
||||||
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
|
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
|
||||||
|
const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null
|
||||||
|
|
||||||
const record = await prisma.user.upsert({
|
const record = await prisma.user.upsert({
|
||||||
where: { email: normalizedEmail },
|
where: { email: normalizedEmail },
|
||||||
|
|
@ -58,6 +110,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
role,
|
role,
|
||||||
tenantId,
|
tenantId,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
companyId,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
|
|
@ -65,6 +118,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
role,
|
role,
|
||||||
tenantId,
|
tenantId,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
companyId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -80,6 +134,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
name: staff.name,
|
name: staff.name,
|
||||||
role: staff.role,
|
role: staff.role,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
companyId: null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
|
|
@ -87,6 +142,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
role: staff.role,
|
role: staff.role,
|
||||||
tenantId,
|
tenantId,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
|
companyId: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
map.set(normalizedEmail, record.id)
|
map.set(normalizedEmail, record.id)
|
||||||
|
|
@ -97,7 +153,7 @@ async function upsertUsers(snapshotUsers) {
|
||||||
const removableStaff = await prisma.user.findMany({
|
const removableStaff = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
role: { in: ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] },
|
role: { in: ["ADMIN", "AGENT", "COLLABORATOR"] },
|
||||||
email: {
|
email: {
|
||||||
notIn: Array.from(allowedStaffEmails),
|
notIn: Array.from(allowedStaffEmails),
|
||||||
},
|
},
|
||||||
|
|
@ -157,7 +213,7 @@ async function upsertQueues(snapshotQueues) {
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertTickets(snapshotTickets, userMap, queueMap) {
|
async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
|
||||||
let created = 0
|
let created = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
|
|
||||||
|
|
@ -171,6 +227,15 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
|
||||||
|
|
||||||
const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null
|
const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null
|
||||||
|
|
||||||
|
let companyId = ticket.companySlug ? companyMap.get(ticket.companySlug) ?? null : null
|
||||||
|
if (!companyId && requesterId) {
|
||||||
|
const requester = await prisma.user.findUnique({
|
||||||
|
where: { id: requesterId },
|
||||||
|
select: { companyId: true },
|
||||||
|
})
|
||||||
|
companyId = requester?.companyId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
|
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
|
||||||
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
|
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
|
||||||
|
|
||||||
|
|
@ -196,6 +261,7 @@ async function upsertTickets(snapshotTickets, userMap, queueMap) {
|
||||||
closedAt: toDate(ticket.closedAt),
|
closedAt: toDate(ticket.closedAt),
|
||||||
createdAt: toDate(ticket.createdAt) ?? new Date(),
|
createdAt: toDate(ticket.createdAt) ?? new Date(),
|
||||||
updatedAt: toDate(ticket.updatedAt) ?? new Date(),
|
updatedAt: toDate(ticket.updatedAt) ?? new Date(),
|
||||||
|
companyId,
|
||||||
}
|
}
|
||||||
|
|
||||||
let ticketRecord
|
let ticketRecord
|
||||||
|
|
@ -264,12 +330,17 @@ async function run() {
|
||||||
tenantId,
|
tenantId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(`Empresas recebidas: ${snapshot.companies.length}`)
|
||||||
console.log(`Usuários recebidos: ${snapshot.users.length}`)
|
console.log(`Usuários recebidos: ${snapshot.users.length}`)
|
||||||
console.log(`Filas recebidas: ${snapshot.queues.length}`)
|
console.log(`Filas recebidas: ${snapshot.queues.length}`)
|
||||||
console.log(`Tickets recebidos: ${snapshot.tickets.length}`)
|
console.log(`Tickets recebidos: ${snapshot.tickets.length}`)
|
||||||
|
|
||||||
|
console.log("Sincronizando empresas no Prisma...")
|
||||||
|
const companyMap = await upsertCompanies(snapshot.companies)
|
||||||
|
console.log(`Empresas ativas no mapa: ${companyMap.size}`)
|
||||||
|
|
||||||
console.log("Sincronizando usuários no Prisma...")
|
console.log("Sincronizando usuários no Prisma...")
|
||||||
const userMap = await upsertUsers(snapshot.users)
|
const userMap = await upsertUsers(snapshot.users, companyMap)
|
||||||
console.log(`Usuários ativos no mapa: ${userMap.size}`)
|
console.log(`Usuários ativos no mapa: ${userMap.size}`)
|
||||||
|
|
||||||
console.log("Sincronizando filas no Prisma...")
|
console.log("Sincronizando filas no Prisma...")
|
||||||
|
|
@ -277,7 +348,7 @@ async function run() {
|
||||||
console.log(`Filas ativas no mapa: ${queueMap.size}`)
|
console.log(`Filas ativas no mapa: ${queueMap.size}`)
|
||||||
|
|
||||||
console.log("Sincronizando tickets no Prisma...")
|
console.log("Sincronizando tickets no Prisma...")
|
||||||
const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap)
|
const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap, companyMap)
|
||||||
console.log(`Tickets criados: ${ticketStats.created}`)
|
console.log(`Tickets criados: ${ticketStats.created}`)
|
||||||
console.log(`Tickets atualizados: ${ticketStats.updated}`)
|
console.log(`Tickets atualizados: ${ticketStats.updated}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,48 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
role: "customer",
|
role: "customer",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
email: "mariana.andrade@atlasengenharia.com.br",
|
||||||
|
password: "manager123",
|
||||||
|
name: "Mariana Andrade",
|
||||||
|
role: "manager",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "fernanda.lima@omnisaude.com.br",
|
||||||
|
password: "manager123",
|
||||||
|
name: "Fernanda Lima",
|
||||||
|
role: "manager",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "joao.ramos@atlasengenharia.com.br",
|
||||||
|
password: "cliente123",
|
||||||
|
name: "João Pedro Ramos",
|
||||||
|
role: "customer",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "aline.rezende@atlasengenharia.com.br",
|
||||||
|
password: "cliente123",
|
||||||
|
name: "Aline Rezende",
|
||||||
|
role: "customer",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "ricardo.matos@omnisaude.com.br",
|
||||||
|
password: "cliente123",
|
||||||
|
name: "Ricardo Matos",
|
||||||
|
role: "customer",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "luciana.prado@omnisaude.com.br",
|
||||||
|
password: "cliente123",
|
||||||
|
name: "Luciana Prado",
|
||||||
|
role: "customer",
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
email: "gabriel.oliveira@rever.com.br",
|
email: "gabriel.oliveira@rever.com.br",
|
||||||
password: "agent123",
|
password: "agent123",
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,13 @@ async function main() {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [users, queues, tickets] = await Promise.all([
|
const [users, queues, tickets, companies] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
include: {
|
include: {
|
||||||
teams: {
|
teams: {
|
||||||
include: { team: true },
|
include: { team: true },
|
||||||
},
|
},
|
||||||
|
company: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.queue.findMany(),
|
prisma.queue.findMany(),
|
||||||
|
|
@ -44,6 +45,7 @@ async function main() {
|
||||||
requester: true,
|
requester: true,
|
||||||
assignee: true,
|
assignee: true,
|
||||||
queue: true,
|
queue: true,
|
||||||
|
company: true,
|
||||||
comments: {
|
comments: {
|
||||||
include: {
|
include: {
|
||||||
author: true,
|
author: true,
|
||||||
|
|
@ -53,6 +55,7 @@ async function main() {
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
}),
|
}),
|
||||||
|
prisma.company.findMany(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const userSnapshot = users.map((user) => ({
|
const userSnapshot = users.map((user) => ({
|
||||||
|
|
@ -63,6 +66,7 @@ async function main() {
|
||||||
teams: user.teams
|
teams: user.teams
|
||||||
.map((membership) => membership.team?.name)
|
.map((membership) => membership.team?.name)
|
||||||
.filter((name) => Boolean(name) && typeof name === "string"),
|
.filter((name) => Boolean(name) && typeof name === "string"),
|
||||||
|
companySlug: user.company?.slug ?? undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const queueSnapshot = queues.map((queue) => ({
|
const queueSnapshot = queues.map((queue) => ({
|
||||||
|
|
@ -78,6 +82,7 @@ async function main() {
|
||||||
const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com"
|
const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com"
|
||||||
const assigneeEmail = ticket.assignee?.email ?? undefined
|
const assigneeEmail = ticket.assignee?.email ?? undefined
|
||||||
const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name)
|
const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name)
|
||||||
|
const companySlug = ticket.company?.slug ?? ticket.requester?.company?.slug ?? undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reference,
|
reference,
|
||||||
|
|
@ -89,6 +94,7 @@ async function main() {
|
||||||
queueSlug: queueSlug ?? undefined,
|
queueSlug: queueSlug ?? undefined,
|
||||||
requesterEmail,
|
requesterEmail,
|
||||||
assigneeEmail,
|
assigneeEmail,
|
||||||
|
companySlug,
|
||||||
dueAt: toMillis(ticket.dueAt) ?? undefined,
|
dueAt: toMillis(ticket.dueAt) ?? undefined,
|
||||||
firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined,
|
firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined,
|
||||||
resolvedAt: toMillis(ticket.resolvedAt) ?? undefined,
|
resolvedAt: toMillis(ticket.resolvedAt) ?? undefined,
|
||||||
|
|
@ -111,12 +117,25 @@ async function main() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const companySnapshot = companies.map((company) => ({
|
||||||
|
slug: company.slug ?? slugify(company.name),
|
||||||
|
name: company.name,
|
||||||
|
cnpj: company.cnpj ?? undefined,
|
||||||
|
domain: company.domain ?? undefined,
|
||||||
|
phone: company.phone ?? undefined,
|
||||||
|
description: company.description ?? undefined,
|
||||||
|
address: company.address ?? undefined,
|
||||||
|
createdAt: toMillis(company.createdAt) ?? Date.now(),
|
||||||
|
updatedAt: toMillis(company.updatedAt) ?? Date.now(),
|
||||||
|
}))
|
||||||
|
|
||||||
const client = new ConvexHttpClient(convexUrl)
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
|
||||||
const result = await client.mutation("migrations:importPrismaSnapshot", {
|
const result = await client.mutation("migrations:importPrismaSnapshot", {
|
||||||
secret,
|
secret,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
tenantId,
|
tenantId,
|
||||||
|
companies: companySnapshot,
|
||||||
users: userSnapshot,
|
users: userSnapshot,
|
||||||
queues: queueSnapshot,
|
queues: queueSnapshot,
|
||||||
tickets: ticketSnapshot,
|
tickets: ticketSnapshot,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,6 @@
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { TicketsPageClient } from "./tickets-page-client"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
|
||||||
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
|
||||||
import { TicketsView } from "@/components/tickets/tickets-view"
|
|
||||||
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
|
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
return (
|
return <TicketsPageClient />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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"
|
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
|
||||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const { convexUserId, isStaff } = useAuth()
|
const { convexUserId, isStaff, role } = useAuth()
|
||||||
|
const isManager = role === "manager"
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||||
const updateComment = useMutation(api.tickets.updateComment)
|
const updateComment = useMutation(api.tickets.updateComment)
|
||||||
|
|
@ -119,6 +120,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const selectedVisibility = isManager ? "PUBLIC" : visibility
|
||||||
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
||||||
const previewsToRevoke = attachments
|
const previewsToRevoke = attachments
|
||||||
.map((attachment) => attachment.previewUrl)
|
.map((attachment) => attachment.previewUrl)
|
||||||
|
|
@ -126,7 +128,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: `temp-${now.getTime()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester,
|
author: ticket.requester,
|
||||||
visibility,
|
visibility: selectedVisibility,
|
||||||
body: sanitizeEditorHtml(body),
|
body: sanitizeEditorHtml(body),
|
||||||
attachments: attachments.map((attachment) => ({
|
attachments: attachments.map((attachment) => ({
|
||||||
id: attachment.storageId,
|
id: attachment.storageId,
|
||||||
|
|
@ -153,7 +155,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
await addComment({
|
await addComment({
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
authorId: convexUserId as Id<"users">,
|
authorId: convexUserId as Id<"users">,
|
||||||
visibility,
|
visibility: selectedVisibility,
|
||||||
body: optimistic.body,
|
body: optimistic.body,
|
||||||
attachments: payload,
|
attachments: payload,
|
||||||
})
|
})
|
||||||
|
|
@ -414,13 +416,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
Visibilidade:
|
Visibilidade:
|
||||||
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
|
<Select
|
||||||
|
value={visibility}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (isManager) return
|
||||||
|
setVisibility(value as "PUBLIC" | "INTERNAL")
|
||||||
|
}}
|
||||||
|
disabled={isManager}
|
||||||
|
>
|
||||||
<SelectTrigger className={selectTriggerClass}>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
<SelectValue placeholder="Visibilidade" />
|
<SelectValue placeholder="Visibilidade" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
<SelectItem value="PUBLIC">Pública</SelectItem>
|
<SelectItem value="PUBLIC">Pública</SelectItem>
|
||||||
<SelectItem value="INTERNAL">Interna</SelectItem>
|
{!isManager ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ function formatDuration(durationMs: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId, role } = useAuth()
|
||||||
|
const isManager = role === "manager"
|
||||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||||
|
|
@ -129,7 +130,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (categoryDirty) {
|
if (categoryDirty && !isManager) {
|
||||||
toast.loading("Atualizando categoria...", { id: "ticket-category" })
|
toast.loading("Atualizando categoria...", { id: "ticket-category" })
|
||||||
try {
|
try {
|
||||||
await updateCategories({
|
await updateCategories({
|
||||||
|
|
@ -147,6 +148,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
})
|
})
|
||||||
throw categoryError
|
throw categoryError
|
||||||
}
|
}
|
||||||
|
} else if (categoryDirty && isManager) {
|
||||||
|
setCategorySelection({
|
||||||
|
categoryId: currentCategoryId,
|
||||||
|
subcategoryId: currentSubcategoryId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
|
|
@ -333,9 +339,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionLabelClass}>Categoria primária</span>
|
<span className={sectionLabelClass}>Categoria primária</span>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
disabled={saving || categoriesLoading}
|
disabled={saving || categoriesLoading || isManager}
|
||||||
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
|
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (isManager) return
|
||||||
if (value === EMPTY_CATEGORY_VALUE) {
|
if (value === EMPTY_CATEGORY_VALUE) {
|
||||||
setCategorySelection({ categoryId: "", subcategoryId: "" })
|
setCategorySelection({ categoryId: "", subcategoryId: "" })
|
||||||
return
|
return
|
||||||
|
|
@ -368,10 +375,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
disabled={
|
disabled={
|
||||||
saving || categoriesLoading || !selectedCategoryId
|
saving || categoriesLoading || !selectedCategoryId || isManager
|
||||||
}
|
}
|
||||||
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
|
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (isManager) return
|
||||||
if (value === EMPTY_SUBCATEGORY_VALUE) {
|
if (value === EMPTY_SUBCATEGORY_VALUE) {
|
||||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
||||||
return
|
return
|
||||||
|
|
@ -407,9 +415,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionLabelClass}>Fila</span>
|
<span className={sectionLabelClass}>Fila</span>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
|
disabled={isManager}
|
||||||
value={ticket.queue ?? ""}
|
value={ticket.queue ?? ""}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
|
if (isManager) return
|
||||||
const queue = queues.find((item) => item.name === value)
|
const queue = queues.find((item) => item.name === value)
|
||||||
if (!queue) return
|
if (!queue) return
|
||||||
toast.loading("Atualizando fila...", { id: "queue" })
|
toast.loading("Atualizando fila...", { id: "queue" })
|
||||||
|
|
@ -444,9 +454,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionLabelClass}>Responsável</span>
|
<span className={sectionLabelClass}>Responsável</span>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
|
disabled={isManager}
|
||||||
value={ticket.assignee?.id ?? ""}
|
value={ticket.assignee?.id ?? ""}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
|
if (isManager) return
|
||||||
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||||
try {
|
try {
|
||||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })
|
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue