sistema-de-chamados/web/convex/migrations.ts
esdrasrenan 854887f499 feat: add company management and manager role support
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-10-06 21:26:43 -03:00

619 lines
18 KiB
TypeScript

import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
function normalizeEmail(value: string) {
return value.trim().toLowerCase()
}
type ImportedUser = {
email: string
name: string
role?: string | null
avatarUrl?: string | null
teams?: string[] | null
companySlug?: string | null
}
type ImportedQueue = {
slug?: string | null
name: string
}
type ImportedCompany = {
slug: string
name: string
cnpj?: string | null
domain?: string | null
phone?: string | null
description?: string | null
address?: string | null
createdAt?: number | null
updatedAt?: number | null
}
function normalizeRole(role: string | null | undefined) {
if (!role) return "AGENT"
const normalized = role.toUpperCase()
if (VALID_ROLES.has(normalized)) return normalized
return "AGENT"
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase()
}
function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
for (const key of Object.keys(input)) {
if (input[key] === undefined) {
delete input[key]
}
}
return input
}
async function ensureUser(
ctx: MutationCtx,
tenantId: string,
data: ImportedUser,
cache: Map<string, Id<"users">>,
companyCache: Map<string, Id<"companies">>
) {
if (cache.has(data.email)) {
return cache.get(data.email)!
}
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email))
.first()
const role = normalizeRole(data.role)
const companyId = data.companySlug ? companyCache.get(data.companySlug) : undefined
const record = existing
? (() => {
const needsPatch =
existing.name !== data.name ||
existing.role !== role ||
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? []) ||
(existing.companyId ?? undefined) !== companyId
if (needsPatch) {
return ctx.db.patch(existing._id, {
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
tenantId,
companyId,
})
}
return Promise.resolve()
})()
: ctx.db.insert("users", {
tenantId,
email: data.email,
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
companyId,
})
const id = existing ? existing._id : ((await record) as Id<"users">)
cache.set(data.email, id)
return id
}
async function ensureQueue(
ctx: MutationCtx,
tenantId: string,
data: ImportedQueue,
cache: Map<string, Id<"queues">>
) {
const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name)
if (cache.has(slug)) return cache.get(slug)!
const bySlug = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (bySlug) {
if (bySlug.name !== data.name) {
await ctx.db.patch(bySlug._id, { name: data.name })
}
cache.set(slug, bySlug._id)
return bySlug._id
}
const byName = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), data.name))
.first()
if (byName) {
if (byName.slug !== slug) {
await ctx.db.patch(byName._id, { slug })
}
cache.set(slug, byName._id)
return byName._id
}
const id = await ctx.db.insert("queues", {
tenantId,
name: data.name,
slug,
teamId: undefined,
})
cache.set(slug, 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) {
return ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
async function getTenantCompanies(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
export const exportTenantSnapshot = query({
args: {
secret: v.string(),
tenantId: v.string(),
},
handler: async (ctx, { secret, tenantId }) => {
if (secret !== SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
const [users, queues, companies] = await Promise.all([
getTenantUsers(ctx, tenantId),
getTenantQueues(ctx, tenantId),
getTenantCompanies(ctx, tenantId),
])
const userMap = new Map(users.map((user) => [user._id, user]))
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
const companyMap = new Map(companies.map((company) => [company._id, company]))
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const ticketsWithRelations = []
for (const ticket of tickets) {
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const requester = userMap.get(ticket.requesterId)
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
const company = ticket.companyId
? companyMap.get(ticket.companyId)
: requester?.companyId
? companyMap.get(requester.companyId)
: undefined
if (!requester) {
continue
}
ticketsWithRelations.push({
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? null,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueSlug: queue?.slug ?? undefined,
requesterEmail: requester.email,
assigneeEmail: assignee?.email ?? undefined,
companySlug: company?.slug ?? undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
tags: ticket.tags ?? [],
comments: comments
.map((comment) => {
const author = userMap.get(comment.authorId)
if (!author) {
return null
}
return {
authorEmail: author.email,
visibility: comment.visibility,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}
})
.filter((value): value is {
authorEmail: string
visibility: string
body: string
createdAt: number
updatedAt: number
} => value !== null),
events: events.map((event) => ({
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})),
})
}
return {
tenantId,
companies: companies.map((company) => ({
slug: company.slug,
name: company.name,
cnpj: company.cnpj ?? null,
domain: company.domain ?? null,
phone: company.phone ?? null,
description: company.description ?? null,
address: company.address ?? null,
createdAt: company.createdAt,
updatedAt: company.updatedAt,
})),
users: users.map((user) => ({
email: user.email,
name: user.name,
role: user.role ?? null,
avatarUrl: user.avatarUrl ?? null,
teams: user.teams ?? [],
companySlug: user.companyId ? companyMap.get(user.companyId)?.slug ?? null : null,
})),
queues: queues.map((queue) => ({
name: queue.name,
slug: queue.slug,
})),
tickets: ticketsWithRelations,
}
},
})
export const importPrismaSnapshot = mutation({
args: {
secret: v.string(),
snapshot: v.object({
tenantId: v.string(),
companies: v.array(
v.object({
slug: v.string(),
name: v.string(),
cnpj: v.optional(v.string()),
domain: v.optional(v.string()),
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
),
users: v.array(
v.object({
email: v.string(),
name: v.string(),
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
companySlug: v.optional(v.string()),
})
),
queues: v.array(
v.object({
name: v.string(),
slug: v.optional(v.string()),
})
),
tickets: v.array(
v.object({
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
queueSlug: v.optional(v.string()),
requesterEmail: v.string(),
assigneeEmail: v.optional(v.string()),
companySlug: v.optional(v.string()),
dueAt: v.optional(v.number()),
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
tags: v.optional(v.array(v.string())),
comments: v.array(
v.object({
authorEmail: v.string(),
visibility: v.string(),
body: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
),
events: v.array(
v.object({
type: v.string(),
payload: v.optional(v.any()),
createdAt: v.number(),
})
),
})
),
}),
},
handler: async (ctx, { secret, snapshot }) => {
if (!SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
if (secret !== SECRET) {
throw new ConvexError("Segredo inválido para sincronização")
}
const companyCache = new Map<string, Id<"companies">>()
const userCache = new Map<string, Id<"users">>()
const queueCache = new Map<string, Id<"queues">>()
for (const company of snapshot.companies) {
await ensureCompany(ctx, snapshot.tenantId, company, companyCache)
}
for (const user of snapshot.users) {
await ensureUser(ctx, snapshot.tenantId, user, userCache, companyCache)
}
for (const queue of snapshot.queues) {
await ensureQueue(ctx, snapshot.tenantId, queue, queueCache)
}
const snapshotStaffEmails = new Set(
snapshot.users
.filter((user) => INTERNAL_STAFF_ROLES.has(normalizeRole(user.role ?? null)))
.map((user) => normalizeEmail(user.email))
)
const existingTenantUsers = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId))
.collect()
for (const user of existingTenantUsers) {
const role = normalizeRole(user.role ?? null)
if (INTERNAL_STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
await ctx.db.delete(user._id)
}
}
let ticketsUpserted = 0
let commentsInserted = 0
let eventsInserted = 0
for (const ticket of snapshot.tickets) {
const requesterId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.requesterEmail,
name: ticket.requesterEmail,
},
userCache,
companyCache
)
const assigneeId = ticket.assigneeEmail
? await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.assigneeEmail,
name: ticket.assigneeEmail,
},
userCache,
companyCache
)
: undefined
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
const companyId = ticket.companySlug ? companyCache.get(ticket.companySlug) ?? (await ensureCompany(ctx, snapshot.tenantId, { slug: ticket.companySlug, name: ticket.companySlug }, companyCache)) : undefined
const existing = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference))
.first()
const payload = pruneUndefined({
tenantId: snapshot.tenantId,
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? undefined,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueId: queueId as Id<"queues"> | undefined,
categoryId: undefined,
subcategoryId: undefined,
requesterId,
assigneeId: assigneeId as Id<"users"> | undefined,
working: false,
slaPolicyId: undefined,
companyId: companyId as Id<"companies"> | undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt,
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
customFields: undefined,
totalWorkedMs: undefined,
activeSessionId: undefined,
})
let ticketId: Id<"tickets">
if (existing) {
await ctx.db.patch(existing._id, payload)
ticketId = existing._id
} else {
ticketId = await ctx.db.insert("tickets", payload)
}
ticketsUpserted += 1
const existingComments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const comment of existingComments) {
await ctx.db.delete(comment._id)
}
const existingEvents = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const event of existingEvents) {
await ctx.db.delete(event._id)
}
for (const comment of ticket.comments) {
const authorId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: comment.authorEmail,
name: comment.authorEmail,
},
userCache,
companyCache
)
await ctx.db.insert("ticketComments", {
ticketId,
authorId,
visibility: comment.visibility,
body: comment.body,
attachments: [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
})
commentsInserted += 1
}
for (const event of ticket.events) {
await ctx.db.insert("ticketEvents", {
ticketId,
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})
eventsInserted += 1
}
}
return {
usersProcessed: userCache.size,
queuesProcessed: queueCache.size,
ticketsUpserted,
commentsInserted,
eventsInserted,
}
},
})