feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
|
|
@ -15,6 +15,9 @@ import type * as categories from "../categories.js";
|
|||
import type * as commentTemplates from "../commentTemplates.js";
|
||||
import type * as companies from "../companies.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||
import type * as deviceFields from "../deviceFields.js";
|
||||
import type * as devices from "../devices.js";
|
||||
import type * as fields from "../fields.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as invites from "../invites.js";
|
||||
|
|
@ -27,6 +30,7 @@ import type * as revision from "../revision.js";
|
|||
import type * as seed from "../seed.js";
|
||||
import type * as slas from "../slas.js";
|
||||
import type * as teams from "../teams.js";
|
||||
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
|
|
@ -52,6 +56,9 @@ declare const fullApi: ApiFromModules<{
|
|||
commentTemplates: typeof commentTemplates;
|
||||
companies: typeof companies;
|
||||
crons: typeof crons;
|
||||
deviceExportTemplates: typeof deviceExportTemplates;
|
||||
deviceFields: typeof deviceFields;
|
||||
devices: typeof devices;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
invites: typeof invites;
|
||||
|
|
@ -64,6 +71,7 @@ declare const fullApi: ApiFromModules<{
|
|||
seed: typeof seed;
|
||||
slas: typeof slas;
|
||||
teams: typeof teams;
|
||||
ticketFormSettings: typeof ticketFormSettings;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
|
|
|||
347
convex/deviceExportTemplates.ts
Normal file
347
convex/deviceExportTemplates.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin, requireUser } from "./rbac"
|
||||
|
||||
type AnyCtx = MutationCtx | QueryCtx
|
||||
|
||||
function normalizeSlug(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
}
|
||||
|
||||
async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"deviceExportTemplates">) {
|
||||
const existing = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
|
||||
.first()
|
||||
|
||||
if (existing && (!excludeId || existing._id !== excludeId)) {
|
||||
throw new ConvexError("Já existe um template com este identificador")
|
||||
}
|
||||
}
|
||||
|
||||
async function unsetDefaults(
|
||||
ctx: MutationCtx,
|
||||
tenantId: string,
|
||||
companyId: Id<"companies"> | undefined | null,
|
||||
excludeId?: Id<"deviceExportTemplates">
|
||||
) {
|
||||
const templates = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
await Promise.all(
|
||||
templates
|
||||
.filter((tpl) => tpl._id !== excludeId)
|
||||
.filter((tpl) => {
|
||||
if (companyId) {
|
||||
return tpl.companyId === companyId
|
||||
}
|
||||
return !tpl.companyId
|
||||
})
|
||||
.map((tpl) => ctx.db.patch(tpl._id, { isDefault: false }))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeColumns(columns: { key: string; label?: string | null }[]) {
|
||||
return columns
|
||||
.map((col) => ({
|
||||
key: col.key.trim(),
|
||||
label: col.label?.trim() || undefined,
|
||||
}))
|
||||
.filter((col) => col.key.length > 0)
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
includeInactive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId, includeInactive }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const templates = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
return templates
|
||||
.filter((tpl) => {
|
||||
if (!includeInactive && tpl.isActive === false) return false
|
||||
if (!companyId) return true
|
||||
if (!tpl.companyId) return true
|
||||
return tpl.companyId === companyId
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
.map((tpl) => ({
|
||||
id: tpl._id,
|
||||
slug: tpl.slug,
|
||||
name: tpl.name,
|
||||
description: tpl.description ?? "",
|
||||
columns: tpl.columns ?? [],
|
||||
filters: tpl.filters ?? null,
|
||||
companyId: tpl.companyId ?? null,
|
||||
isDefault: Boolean(tpl.isDefault),
|
||||
isActive: tpl.isActive ?? true,
|
||||
createdAt: tpl.createdAt,
|
||||
updatedAt: tpl.updatedAt,
|
||||
createdBy: tpl.createdBy ?? null,
|
||||
updatedBy: tpl.updatedBy ?? null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const listForTenant = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
||||
await requireUser(ctx, viewerId, tenantId)
|
||||
const templates = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
return templates
|
||||
.filter((tpl) => tpl.isActive !== false)
|
||||
.filter((tpl) => {
|
||||
if (!companyId) return !tpl.companyId
|
||||
return !tpl.companyId || tpl.companyId === companyId
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
.map((tpl) => ({
|
||||
id: tpl._id,
|
||||
slug: tpl.slug,
|
||||
name: tpl.name,
|
||||
description: tpl.description ?? "",
|
||||
columns: tpl.columns ?? [],
|
||||
filters: tpl.filters ?? null,
|
||||
companyId: tpl.companyId ?? null,
|
||||
isDefault: Boolean(tpl.isDefault),
|
||||
isActive: tpl.isActive ?? true,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const getDefault = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
||||
await requireUser(ctx, viewerId, tenantId)
|
||||
const indexQuery = companyId
|
||||
? ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
: ctx.db.query("deviceExportTemplates").withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true))
|
||||
|
||||
const templates = await indexQuery.collect()
|
||||
const candidate = templates.find((tpl) => tpl.isDefault) ?? null
|
||||
if (candidate) {
|
||||
return {
|
||||
id: candidate._id,
|
||||
slug: candidate.slug,
|
||||
name: candidate.name,
|
||||
description: candidate.description ?? "",
|
||||
columns: candidate.columns ?? [],
|
||||
filters: candidate.filters ?? null,
|
||||
companyId: candidate.companyId ?? null,
|
||||
isDefault: Boolean(candidate.isDefault),
|
||||
isActive: candidate.isActive ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
const globalDefault = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true))
|
||||
.first()
|
||||
|
||||
if (globalDefault) {
|
||||
return {
|
||||
id: globalDefault._id,
|
||||
slug: globalDefault.slug,
|
||||
name: globalDefault.name,
|
||||
description: globalDefault.description ?? "",
|
||||
columns: globalDefault.columns ?? [],
|
||||
filters: globalDefault.filters ?? null,
|
||||
companyId: globalDefault.companyId ?? null,
|
||||
isDefault: Boolean(globalDefault.isDefault),
|
||||
isActive: globalDefault.isActive ?? true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
columns: v.array(
|
||||
v.object({
|
||||
key: v.string(),
|
||||
label: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
filters: v.optional(v.any()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
isDefault: v.optional(v.boolean()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const normalizedName = args.name.trim()
|
||||
if (normalizedName.length < 3) {
|
||||
throw new ConvexError("Informe um nome para o template")
|
||||
}
|
||||
const slug = normalizeSlug(normalizedName)
|
||||
if (!slug) {
|
||||
throw new ConvexError("Não foi possível gerar um identificador para o template")
|
||||
}
|
||||
await ensureUniqueSlug(ctx, args.tenantId, slug)
|
||||
|
||||
const columns = normalizeColumns(args.columns)
|
||||
if (columns.length === 0) {
|
||||
throw new ConvexError("Selecione ao menos uma coluna")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const templateId = await ctx.db.insert("deviceExportTemplates", {
|
||||
tenantId: args.tenantId,
|
||||
name: normalizedName,
|
||||
slug,
|
||||
description: args.description ?? undefined,
|
||||
columns,
|
||||
filters: args.filters ?? undefined,
|
||||
companyId: args.companyId ?? undefined,
|
||||
isDefault: Boolean(args.isDefault),
|
||||
isActive: args.isActive ?? true,
|
||||
createdBy: args.actorId,
|
||||
updatedBy: args.actorId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (args.isDefault) {
|
||||
await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, templateId)
|
||||
}
|
||||
|
||||
return templateId
|
||||
},
|
||||
})
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
templateId: v.id("deviceExportTemplates"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
columns: v.array(
|
||||
v.object({
|
||||
key: v.string(),
|
||||
label: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
filters: v.optional(v.any()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
isDefault: v.optional(v.boolean()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const template = await ctx.db.get(args.templateId)
|
||||
if (!template || template.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Template não encontrado")
|
||||
}
|
||||
const normalizedName = args.name.trim()
|
||||
if (normalizedName.length < 3) {
|
||||
throw new ConvexError("Informe um nome para o template")
|
||||
}
|
||||
let slug = template.slug
|
||||
if (template.name !== normalizedName) {
|
||||
slug = normalizeSlug(normalizedName)
|
||||
if (!slug) throw new ConvexError("Não foi possível gerar um identificador para o template")
|
||||
await ensureUniqueSlug(ctx, args.tenantId, slug, args.templateId)
|
||||
}
|
||||
|
||||
const columns = normalizeColumns(args.columns)
|
||||
if (columns.length === 0) {
|
||||
throw new ConvexError("Selecione ao menos uma coluna")
|
||||
}
|
||||
|
||||
const isDefault = Boolean(args.isDefault)
|
||||
await ctx.db.patch(args.templateId, {
|
||||
name: normalizedName,
|
||||
slug,
|
||||
description: args.description ?? undefined,
|
||||
columns,
|
||||
filters: args.filters ?? undefined,
|
||||
companyId: args.companyId ?? undefined,
|
||||
isDefault,
|
||||
isActive: args.isActive ?? true,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: args.actorId,
|
||||
})
|
||||
|
||||
if (isDefault) {
|
||||
await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, args.templateId)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
templateId: v.id("deviceExportTemplates"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const template = await ctx.db.get(args.templateId)
|
||||
if (!template || template.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Template não encontrado")
|
||||
}
|
||||
await ctx.db.delete(args.templateId)
|
||||
},
|
||||
})
|
||||
|
||||
export const setDefault = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
templateId: v.id("deviceExportTemplates"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const template = await ctx.db.get(args.templateId)
|
||||
if (!template || template.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Template não encontrado")
|
||||
}
|
||||
await unsetDefaults(ctx, args.tenantId, template.companyId ?? null, args.templateId)
|
||||
await ctx.db.patch(args.templateId, {
|
||||
isDefault: true,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: args.actorId,
|
||||
})
|
||||
},
|
||||
})
|
||||
271
convex/deviceFields.ts
Normal file
271
convex/deviceFields.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin, requireUser } from "./rbac"
|
||||
|
||||
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const
|
||||
type FieldType = (typeof FIELD_TYPES)[number]
|
||||
|
||||
type AnyCtx = MutationCtx | QueryCtx
|
||||
|
||||
function normalizeKey(label: string) {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
}
|
||||
|
||||
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"deviceFields">) {
|
||||
const existing = await ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
|
||||
.first()
|
||||
if (existing && (!excludeId || existing._id !== excludeId)) {
|
||||
throw new ConvexError("Já existe um campo com este identificador")
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) {
|
||||
if (type === "select" && (!options || options.length === 0)) {
|
||||
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção")
|
||||
}
|
||||
}
|
||||
|
||||
function matchesScope(fieldScope: string | undefined, scope: string | undefined) {
|
||||
if (!scope || scope === "all") return true
|
||||
if (!fieldScope || fieldScope === "all") return true
|
||||
return fieldScope === scope
|
||||
}
|
||||
|
||||
function matchesCompany(fieldCompanyId: Id<"companies"> | undefined, companyId: Id<"companies"> | undefined, includeScoped?: boolean) {
|
||||
if (!companyId) {
|
||||
if (includeScoped) return true
|
||||
return fieldCompanyId ? false : true
|
||||
}
|
||||
return !fieldCompanyId || fieldCompanyId === companyId
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const fieldsQuery = ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
|
||||
const fields = await fieldsQuery.collect()
|
||||
return fields
|
||||
.filter((field) => matchesCompany(field.companyId, companyId, true))
|
||||
.filter((field) => matchesScope(field.scope, scope))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
description: field.description ?? "",
|
||||
type: field.type as FieldType,
|
||||
required: Boolean(field.required),
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
companyId: field.companyId ?? null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const listForTenant = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
|
||||
await requireUser(ctx, viewerId, tenantId)
|
||||
const fields = await ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
return fields
|
||||
.filter((field) => matchesCompany(field.companyId, companyId, false))
|
||||
.filter((field) => matchesScope(field.scope, scope))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
description: field.description ?? "",
|
||||
type: field.type as FieldType,
|
||||
required: Boolean(field.required),
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
companyId: field.companyId ?? null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
required: v.optional(v.boolean()),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const normalizedLabel = args.label.trim()
|
||||
if (normalizedLabel.length < 2) {
|
||||
throw new ConvexError("Informe um rótulo para o campo")
|
||||
}
|
||||
if (!FIELD_TYPES.includes(args.type as FieldType)) {
|
||||
throw new ConvexError("Tipo de campo inválido")
|
||||
}
|
||||
validateOptions(args.type as FieldType, args.options ?? undefined)
|
||||
|
||||
const key = normalizeKey(normalizedLabel)
|
||||
await ensureUniqueKey(ctx, args.tenantId, key)
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", args.tenantId))
|
||||
.collect()
|
||||
const maxOrder = existing.reduce((acc, item) => Math.max(acc, item.order ?? 0), 0)
|
||||
const now = Date.now()
|
||||
|
||||
const id = await ctx.db.insert("deviceFields", {
|
||||
tenantId: args.tenantId,
|
||||
key,
|
||||
label: normalizedLabel,
|
||||
description: args.description ?? undefined,
|
||||
type: args.type,
|
||||
required: Boolean(args.required),
|
||||
options: args.options ?? undefined,
|
||||
scope: args.scope ?? "all",
|
||||
companyId: args.companyId ?? undefined,
|
||||
order: maxOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: args.actorId,
|
||||
updatedBy: args.actorId,
|
||||
})
|
||||
|
||||
return id
|
||||
},
|
||||
})
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
fieldId: v.id("deviceFields"),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
required: v.optional(v.boolean()),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const field = await ctx.db.get(args.fieldId)
|
||||
if (!field || field.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Campo não encontrado")
|
||||
}
|
||||
if (!FIELD_TYPES.includes(args.type as FieldType)) {
|
||||
throw new ConvexError("Tipo de campo inválido")
|
||||
}
|
||||
const normalizedLabel = args.label.trim()
|
||||
if (normalizedLabel.length < 2) {
|
||||
throw new ConvexError("Informe um rótulo para o campo")
|
||||
}
|
||||
validateOptions(args.type as FieldType, args.options ?? undefined)
|
||||
|
||||
let key = field.key
|
||||
if (field.label !== normalizedLabel) {
|
||||
key = normalizeKey(normalizedLabel)
|
||||
await ensureUniqueKey(ctx, args.tenantId, key, args.fieldId)
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.fieldId, {
|
||||
key,
|
||||
label: normalizedLabel,
|
||||
description: args.description ?? undefined,
|
||||
type: args.type,
|
||||
required: Boolean(args.required),
|
||||
options: args.options ?? undefined,
|
||||
scope: args.scope ?? "all",
|
||||
companyId: args.companyId ?? undefined,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: args.actorId,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
fieldId: v.id("deviceFields"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const field = await ctx.db.get(args.fieldId)
|
||||
if (!field || field.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Campo não encontrado")
|
||||
}
|
||||
await ctx.db.delete(args.fieldId)
|
||||
},
|
||||
})
|
||||
|
||||
export const reorder = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
orderedIds: v.array(v.id("deviceFields")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const now = Date.now()
|
||||
await Promise.all(
|
||||
args.orderedIds.map((fieldId, index) =>
|
||||
ctx.db.patch(fieldId, {
|
||||
order: index + 1,
|
||||
updatedAt: now,
|
||||
updatedBy: args.actorId,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
1
convex/devices.ts
Normal file
1
convex/devices.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./machines"
|
||||
|
|
@ -38,8 +38,8 @@ function validateOptions(type: FieldType, options: { value: string; label: strin
|
|||
}
|
||||
|
||||
export const list = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, scope }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId);
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -47,6 +47,12 @@ export const list = query({
|
|||
.collect();
|
||||
|
||||
return fields
|
||||
.filter((field) => {
|
||||
if (!scope) return true;
|
||||
const fieldScope = (field.scope ?? "all").trim();
|
||||
if (fieldScope === "all" || fieldScope.length === 0) return true;
|
||||
return fieldScope === scope;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
|
|
@ -57,6 +63,7 @@ export const list = query({
|
|||
required: field.required,
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
createdAt: field.createdAt,
|
||||
updatedAt: field.updatedAt,
|
||||
}));
|
||||
|
|
@ -64,8 +71,8 @@ export const list = query({
|
|||
});
|
||||
|
||||
export const listForTenant = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, scope }) => {
|
||||
await requireUser(ctx, viewerId, tenantId);
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -73,6 +80,12 @@ export const listForTenant = query({
|
|||
.collect();
|
||||
|
||||
return fields
|
||||
.filter((field) => {
|
||||
if (!scope) return true;
|
||||
const fieldScope = (field.scope ?? "all").trim();
|
||||
if (fieldScope === "all" || fieldScope.length === 0) return true;
|
||||
return fieldScope === scope;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
|
|
@ -83,6 +96,7 @@ export const listForTenant = query({
|
|||
required: field.required,
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -103,8 +117,9 @@ export const create = mutation({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
|
||||
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const normalizedLabel = label.trim();
|
||||
if (normalizedLabel.length < 2) {
|
||||
|
|
@ -116,6 +131,15 @@ export const create = mutation({
|
|||
validateOptions(type as FieldType, options ?? undefined);
|
||||
const key = normalizeKey(normalizedLabel);
|
||||
await ensureUniqueKey(ctx, tenantId, key);
|
||||
const normalizedScope = (() => {
|
||||
const raw = scope?.trim();
|
||||
if (!raw || raw.length === 0) return "all";
|
||||
const safe = raw.toLowerCase();
|
||||
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
|
||||
throw new ConvexError("Escopo inválido para o campo");
|
||||
}
|
||||
return safe;
|
||||
})();
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -133,6 +157,7 @@ export const create = mutation({
|
|||
required,
|
||||
options,
|
||||
order: maxOrder + 1,
|
||||
scope: normalizedScope,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
@ -157,8 +182,9 @@ export const update = mutation({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
|
||||
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const field = await ctx.db.get(fieldId);
|
||||
if (!field || field.tenantId !== tenantId) {
|
||||
|
|
@ -173,6 +199,16 @@ export const update = mutation({
|
|||
}
|
||||
validateOptions(type as FieldType, options ?? undefined);
|
||||
|
||||
const normalizedScope = (() => {
|
||||
const raw = scope?.trim();
|
||||
if (!raw || raw.length === 0) return "all";
|
||||
const safe = raw.toLowerCase();
|
||||
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
|
||||
throw new ConvexError("Escopo inválido para o campo");
|
||||
}
|
||||
return safe;
|
||||
})();
|
||||
|
||||
let key = field.key;
|
||||
if (field.label !== normalizedLabel) {
|
||||
key = normalizeKey(normalizedLabel);
|
||||
|
|
@ -186,6 +222,7 @@ export const update = mutation({
|
|||
type,
|
||||
required,
|
||||
options,
|
||||
scope: normalizedScope,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { randomBytes } from "@noble/hashes/utils"
|
|||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { normalizeStatus } from "./tickets"
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
|
|
@ -65,6 +66,14 @@ function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]):
|
|||
return { macs, serials }
|
||||
}
|
||||
|
||||
function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers {
|
||||
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
|
||||
const normalizeSerial = (value: string) => value.trim().toLowerCase()
|
||||
const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort()
|
||||
const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort()
|
||||
return { macs, serials }
|
||||
}
|
||||
|
||||
async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
|
||||
const tokens = await ctx.db
|
||||
.query("machineTokens")
|
||||
|
|
@ -93,6 +102,51 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
|
|||
return toHex(sha256(payload))
|
||||
}
|
||||
|
||||
function generateManualFingerprint(tenantId: string, displayName: string) {
|
||||
const payload = JSON.stringify({
|
||||
tenantId,
|
||||
displayName: displayName.trim().toLowerCase(),
|
||||
nonce: toHex(randomBytes(16)),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
return toHex(sha256(payload))
|
||||
}
|
||||
|
||||
function formatDeviceCustomFieldDisplay(
|
||||
type: string,
|
||||
value: unknown,
|
||||
options?: Array<{ value: string; label: string }>
|
||||
): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
switch (type) {
|
||||
case "text":
|
||||
return String(value).trim()
|
||||
case "number": {
|
||||
const num = typeof value === "number" ? value : Number(value)
|
||||
if (!Number.isFinite(num)) return null
|
||||
return String(num)
|
||||
}
|
||||
case "boolean":
|
||||
return value ? "Sim" : "Não"
|
||||
case "date": {
|
||||
const date = value instanceof Date ? value : new Date(String(value))
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
case "select": {
|
||||
const raw = String(value)
|
||||
const option = options?.find((opt) => opt.value === raw || opt.label === raw)
|
||||
return option?.label ?? raw
|
||||
}
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractCollaboratorEmail(metadata: unknown): string | null {
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
const record = metadata as Record<string, unknown>
|
||||
|
|
@ -126,18 +180,18 @@ async function getActiveToken(
|
|||
.unique()
|
||||
|
||||
if (!token) {
|
||||
throw new ConvexError("Token de máquina inválido")
|
||||
throw new ConvexError("Token de dispositivo inválido")
|
||||
}
|
||||
if (token.revoked) {
|
||||
throw new ConvexError("Token de máquina revogado")
|
||||
throw new ConvexError("Token de dispositivo revogado")
|
||||
}
|
||||
if (token.expiresAt < Date.now()) {
|
||||
throw new ConvexError("Token de máquina expirado")
|
||||
throw new ConvexError("Token de dispositivo expirado")
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(token.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada para o token fornecido")
|
||||
throw new ConvexError("Dispositivo não encontrada para o token fornecido")
|
||||
}
|
||||
|
||||
return { token, machine }
|
||||
|
|
@ -381,7 +435,7 @@ async function evaluatePostureAndMaybeRaise(
|
|||
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
|
||||
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
|
||||
|
||||
const subject = `Alerta de máquina: ${machine.hostname}`
|
||||
const subject = `Alerta de dispositivo: ${machine.hostname}`
|
||||
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
|
||||
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
|
||||
}
|
||||
|
|
@ -445,6 +499,7 @@ export const register = mutation({
|
|||
companyId: companyId ?? existing.companyId,
|
||||
companySlug: companySlug ?? existing.companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: existing.displayName ?? args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -457,6 +512,9 @@ export const register = mutation({
|
|||
status: "online",
|
||||
isActive: true,
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
deviceType: existing.deviceType ?? "desktop",
|
||||
devicePlatform: args.os.name ?? existing.devicePlatform,
|
||||
managementMode: existing.managementMode ?? "agent",
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
|
|
@ -470,6 +528,7 @@ export const register = mutation({
|
|||
companyId,
|
||||
companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -483,6 +542,9 @@ export const register = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
deviceType: "desktop",
|
||||
devicePlatform: args.os.name,
|
||||
managementMode: "agent",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
|
|
@ -582,6 +644,7 @@ export const upsertInventory = mutation({
|
|||
companyId: companyId ?? existing.companyId,
|
||||
companySlug: companySlug ?? existing.companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: existing.displayName ?? args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -592,6 +655,9 @@ export const upsertInventory = mutation({
|
|||
updatedAt: now,
|
||||
status: args.metrics ? "online" : existing.status ?? "unknown",
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
deviceType: existing.deviceType ?? "desktop",
|
||||
devicePlatform: args.os.name ?? existing.devicePlatform,
|
||||
managementMode: existing.managementMode ?? "agent",
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
|
|
@ -605,6 +671,7 @@ export const upsertInventory = mutation({
|
|||
companyId,
|
||||
companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -617,6 +684,9 @@ export const upsertInventory = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
deviceType: "desktop",
|
||||
devicePlatform: args.os.name,
|
||||
managementMode: "agent",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
|
|
@ -673,9 +743,13 @@ export const heartbeat = mutation({
|
|||
|
||||
await ctx.db.patch(machine._id, {
|
||||
hostname: args.hostname ?? machine.hostname,
|
||||
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
|
||||
osName: args.os?.name ?? machine.osName,
|
||||
osVersion: args.os?.version ?? machine.osVersion,
|
||||
architecture: args.os?.architecture ?? machine.architecture,
|
||||
devicePlatform: args.os?.name ?? machine.devicePlatform,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
lastHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
status: args.status ?? "online",
|
||||
|
|
@ -839,6 +913,11 @@ export const listByTenant = query({
|
|||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
displayName: machine.displayName ?? null,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
devicePlatform: machine.devicePlatform ?? null,
|
||||
deviceProfile: machine.deviceProfile ?? null,
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
|
|
@ -873,6 +952,7 @@ export const listByTenant = query({
|
|||
inventory,
|
||||
postureAlerts,
|
||||
lastPostureAt,
|
||||
customFields: machine.customFields ?? [],
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -957,6 +1037,11 @@ export async function getByIdHandler(
|
|||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
displayName: machine.displayName ?? null,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
devicePlatform: machine.devicePlatform ?? null,
|
||||
deviceProfile: machine.deviceProfile ?? null,
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
|
|
@ -992,6 +1077,7 @@ export async function getByIdHandler(
|
|||
postureAlerts,
|
||||
lastPostureAt,
|
||||
remoteAccess: machine.remoteAccess ?? null,
|
||||
customFields: machine.customFields ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1333,7 +1419,7 @@ export async function updatePersonaHandler(
|
|||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
let nextPersona = machine.persona ?? undefined
|
||||
|
|
@ -1343,7 +1429,7 @@ export async function updatePersonaHandler(
|
|||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
throw new ConvexError("Perfil inválido para a máquina")
|
||||
throw new ConvexError("Perfil inválido para a dispositivo")
|
||||
} else {
|
||||
nextPersona = trimmed
|
||||
}
|
||||
|
|
@ -1380,7 +1466,7 @@ export async function updatePersonaHandler(
|
|||
}
|
||||
|
||||
if (nextPersona && !nextAssignedUserId) {
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da dispositivo")
|
||||
}
|
||||
|
||||
if (nextPersona && nextAssignedUserId) {
|
||||
|
|
@ -1435,6 +1521,196 @@ export async function updatePersonaHandler(
|
|||
return { ok: true, persona: nextPersona ?? null }
|
||||
}
|
||||
|
||||
export const saveDeviceCustomFields = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
machineId: v.id("machines"),
|
||||
fields: v.array(
|
||||
v.object({
|
||||
fieldId: v.id("deviceFields"),
|
||||
value: v.any(),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine || machine.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Dispositivo não encontrado")
|
||||
}
|
||||
|
||||
const companyId = machine.companyId ?? null
|
||||
const deviceType = (machine.deviceType ?? "desktop").toLowerCase()
|
||||
|
||||
const entries = await Promise.all(
|
||||
args.fields.map(async ({ fieldId, value }) => {
|
||||
const definition = await ctx.db.get(fieldId)
|
||||
if (!definition || definition.tenantId !== args.tenantId) {
|
||||
return null
|
||||
}
|
||||
if (companyId && definition.companyId && definition.companyId !== companyId) {
|
||||
return null
|
||||
}
|
||||
if (!companyId && definition.companyId) {
|
||||
return null
|
||||
}
|
||||
const scope = (definition.scope ?? "all").toLowerCase()
|
||||
if (scope !== "all" && scope !== deviceType) {
|
||||
return null
|
||||
}
|
||||
const displayValue =
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: formatDeviceCustomFieldDisplay(definition.type, value, definition.options ?? undefined)
|
||||
return {
|
||||
fieldId: definition._id,
|
||||
fieldKey: definition.key,
|
||||
label: definition.label,
|
||||
type: definition.type,
|
||||
value: value ?? null,
|
||||
displayValue: displayValue ?? undefined,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const customFields = entries.filter(Boolean) as Array<{
|
||||
fieldId: Id<"deviceFields">
|
||||
fieldKey: string
|
||||
label: string
|
||||
type: string
|
||||
value: unknown
|
||||
displayValue?: string
|
||||
}>
|
||||
|
||||
await ctx.db.patch(args.machineId, {
|
||||
customFields,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const saveDeviceProfile = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
machineId: v.optional(v.id("machines")),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
companySlug: v.optional(v.string()),
|
||||
displayName: v.string(),
|
||||
hostname: v.optional(v.string()),
|
||||
deviceType: v.string(),
|
||||
devicePlatform: v.optional(v.string()),
|
||||
osName: v.optional(v.string()),
|
||||
osVersion: v.optional(v.string()),
|
||||
macAddresses: v.optional(v.array(v.string())),
|
||||
serialNumbers: v.optional(v.array(v.string())),
|
||||
profile: v.optional(v.any()),
|
||||
status: v.optional(v.string()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const displayName = args.displayName.trim()
|
||||
if (!displayName) {
|
||||
throw new ConvexError("Informe o nome do dispositivo")
|
||||
}
|
||||
const hostname = (args.hostname ?? displayName).trim()
|
||||
if (!hostname) {
|
||||
throw new ConvexError("Informe o identificador do dispositivo")
|
||||
}
|
||||
|
||||
const normalizedType = (() => {
|
||||
const candidate = args.deviceType.trim().toLowerCase()
|
||||
if (["desktop", "mobile", "tablet"].includes(candidate)) return candidate
|
||||
return "desktop"
|
||||
})()
|
||||
const normalizedPlatform = args.devicePlatform?.trim() || args.osName?.trim() || null
|
||||
const normalizedStatus = (args.status ?? "unknown").trim() || "unknown"
|
||||
const normalizedSlug = args.companySlug?.trim() || undefined
|
||||
const osNameInput = args.osName === undefined ? undefined : args.osName.trim()
|
||||
const osVersionInput = args.osVersion === undefined ? undefined : args.osVersion.trim()
|
||||
const now = Date.now()
|
||||
|
||||
if (args.machineId) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine || machine.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Dispositivo não encontrado para atualização")
|
||||
}
|
||||
if (machine.managementMode && machine.managementMode !== "manual") {
|
||||
throw new ConvexError("Somente dispositivos manuais podem ser editados por esta ação")
|
||||
}
|
||||
|
||||
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const macAddresses =
|
||||
args.macAddresses === undefined ? machine.macAddresses : normalizedIdentifiers.macs
|
||||
const serialNumbers =
|
||||
args.serialNumbers === undefined ? machine.serialNumbers : normalizedIdentifiers.serials
|
||||
const deviceProfilePatch = args.profile === undefined ? undefined : args.profile ?? null
|
||||
|
||||
const osNameValue = osNameInput === undefined ? machine.osName : osNameInput || machine.osName
|
||||
const osVersionValue =
|
||||
osVersionInput === undefined ? machine.osVersion ?? undefined : osVersionInput || undefined
|
||||
|
||||
await ctx.db.patch(args.machineId, {
|
||||
companyId: args.companyId ?? machine.companyId ?? undefined,
|
||||
companySlug: normalizedSlug ?? machine.companySlug ?? undefined,
|
||||
hostname,
|
||||
displayName,
|
||||
osName: osNameValue,
|
||||
osVersion: osVersionValue,
|
||||
macAddresses,
|
||||
serialNumbers,
|
||||
deviceType: normalizedType,
|
||||
devicePlatform: normalizedPlatform ?? machine.devicePlatform ?? undefined,
|
||||
deviceProfile: deviceProfilePatch,
|
||||
managementMode: "manual",
|
||||
status: normalizedStatus,
|
||||
isActive: args.isActive ?? machine.isActive ?? true,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return { machineId: args.machineId }
|
||||
}
|
||||
|
||||
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = generateManualFingerprint(args.tenantId, displayName)
|
||||
const deviceProfile = args.profile ?? undefined
|
||||
const osNameValue = osNameInput || normalizedPlatform || "Desconhecido"
|
||||
const osVersionValue = osVersionInput || undefined
|
||||
|
||||
const machineId = await ctx.db.insert("machines", {
|
||||
tenantId: args.tenantId,
|
||||
companyId: args.companyId ?? undefined,
|
||||
companySlug: normalizedSlug ?? undefined,
|
||||
hostname,
|
||||
displayName,
|
||||
osName: osNameValue,
|
||||
osVersion: osVersionValue,
|
||||
macAddresses: normalizedIdentifiers.macs,
|
||||
serialNumbers: normalizedIdentifiers.serials,
|
||||
fingerprint,
|
||||
metadata: undefined,
|
||||
deviceType: normalizedType,
|
||||
devicePlatform: normalizedPlatform ?? undefined,
|
||||
deviceProfile,
|
||||
managementMode: "manual",
|
||||
status: normalizedStatus,
|
||||
isActive: args.isActive ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: "manual",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
assignedUserName: undefined,
|
||||
assignedUserRole: undefined,
|
||||
})
|
||||
|
||||
return { machineId }
|
||||
},
|
||||
})
|
||||
|
||||
export const updatePersona = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
@ -1454,7 +1730,7 @@ export const getContext = query({
|
|||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const linkedUserIds = machine.linkedUserIds ?? []
|
||||
|
|
@ -1515,7 +1791,7 @@ export const linkAuthAccount = mutation({
|
|||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machine._id, {
|
||||
|
|
@ -1535,7 +1811,7 @@ export const linkUser = mutation({
|
|||
},
|
||||
handler: async (ctx, { machineId, email }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) throw new ConvexError("Máquina não encontrada")
|
||||
if (!machine) throw new ConvexError("Dispositivo não encontrada")
|
||||
const tenantId = machine.tenantId
|
||||
const normalized = email.trim().toLowerCase()
|
||||
|
||||
|
|
@ -1546,7 +1822,7 @@ export const linkUser = mutation({
|
|||
if (!user) throw new ConvexError("Usuário não encontrado")
|
||||
const role = (user.role ?? "").toUpperCase()
|
||||
if (role === 'ADMIN' || role === 'AGENT') {
|
||||
throw new ConvexError('Usuários administrativos não podem ser vinculados a máquinas')
|
||||
throw new ConvexError('Usuários administrativos não podem ser vinculados a dispositivos')
|
||||
}
|
||||
|
||||
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
|
||||
|
|
@ -1563,7 +1839,7 @@ export const unlinkUser = mutation({
|
|||
},
|
||||
handler: async (ctx, { machineId, userId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) throw new ConvexError("Máquina não encontrada")
|
||||
if (!machine) throw new ConvexError("Dispositivo não encontrada")
|
||||
const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId)
|
||||
await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() })
|
||||
return { ok: true }
|
||||
|
|
@ -1580,25 +1856,29 @@ export const rename = mutation({
|
|||
// Reutiliza requireStaff através de tickets.ts helpers
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
// Verifica permissão no tenant da máquina
|
||||
// Verifica permissão no tenant da dispositivo
|
||||
const viewer = await ctx.db.get(actorId)
|
||||
if (!viewer || viewer.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
||||
throw new ConvexError("Apenas equipe interna pode renomear dispositivos")
|
||||
}
|
||||
|
||||
const nextName = hostname.trim()
|
||||
if (nextName.length < 2) {
|
||||
throw new ConvexError("Informe um nome válido para a máquina")
|
||||
throw new ConvexError("Informe um nome válido para a dispositivo")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
|
||||
await ctx.db.patch(machineId, {
|
||||
hostname: nextName,
|
||||
displayName: nextName,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
|
@ -1612,17 +1892,17 @@ export const toggleActive = mutation({
|
|||
handler: async (ctx, { machineId, actorId, active }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina")
|
||||
throw new ConvexError("Apenas equipe interna pode atualizar o status da dispositivo")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machineId, {
|
||||
|
|
@ -1642,17 +1922,17 @@ export const resetAgent = mutation({
|
|||
handler: async (ctx, { machineId, actorId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode resetar o agente da máquina")
|
||||
throw new ConvexError("Apenas equipe interna pode resetar o agente da dispositivo")
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
|
|
@ -1808,12 +2088,12 @@ export const updateRemoteAccess = mutation({
|
|||
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
|
|
@ -1937,17 +2217,17 @@ export const remove = mutation({
|
|||
handler: async (ctx, { machineId, actorId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const role = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(role)) {
|
||||
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
||||
throw new ConvexError("Apenas equipe interna pode excluir dispositivos")
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
|
|
|
|||
|
|
@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function extractMaxScore(payload: unknown): number | null {
|
||||
if (payload && typeof payload === "object" && "maxScore" in payload) {
|
||||
const value = (payload as { maxScore: unknown }).maxScore;
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractComment(payload: unknown): string | null {
|
||||
if (payload && typeof payload === "object" && "comment" in payload) {
|
||||
const value = (payload as { comment: unknown }).comment;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAssignee(payload: unknown): { id: string | null; name: string | null } {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { id: null, name: null }
|
||||
}
|
||||
const record = payload as Record<string, unknown>
|
||||
const rawId = record["assigneeId"]
|
||||
const rawName = record["assigneeName"]
|
||||
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null
|
||||
const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
|
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
|
|||
.collect();
|
||||
}
|
||||
|
||||
async function fetchScopedTicketsByResolvedRange(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
companyId?: Id<"companies">,
|
||||
) {
|
||||
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_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("queues")
|
||||
|
|
@ -159,12 +230,47 @@ type CsatSurvey = {
|
|||
ticketId: Id<"tickets">;
|
||||
reference: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
comment: string | null;
|
||||
receivedAt: number;
|
||||
assigneeId: string | null;
|
||||
assigneeName: string | null;
|
||||
};
|
||||
|
||||
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
|
||||
const perTicket = await Promise.all(
|
||||
tickets.map(async (ticket) => {
|
||||
if (typeof ticket.csatScore === "number") {
|
||||
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
||||
name?: string
|
||||
email?: string
|
||||
} | null
|
||||
const assigneeId =
|
||||
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
||||
? ticket.csatAssigneeId
|
||||
: ticket.csatAssigneeId
|
||||
? String(ticket.csatAssigneeId)
|
||||
: null
|
||||
const assigneeName =
|
||||
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
||||
? snapshot.name.trim()
|
||||
: null
|
||||
return [
|
||||
{
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score: ticket.csatScore,
|
||||
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
|
||||
comment:
|
||||
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
||||
? ticket.csatComment.trim()
|
||||
: null,
|
||||
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
|
||||
assigneeId,
|
||||
assigneeName,
|
||||
} satisfies CsatSurvey,
|
||||
];
|
||||
}
|
||||
const events = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
||||
|
|
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
|
|||
.map((event) => {
|
||||
const score = extractScore(event.payload);
|
||||
if (score === null) return null;
|
||||
const assignee = extractAssignee(event.payload)
|
||||
return {
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score,
|
||||
maxScore: extractMaxScore(event.payload) ?? 5,
|
||||
comment: extractComment(event.payload),
|
||||
receivedAt: event.createdAt,
|
||||
assigneeId: assignee.id,
|
||||
assigneeName: assignee.name,
|
||||
} as CsatSurvey;
|
||||
})
|
||||
.filter(isNotNull);
|
||||
|
|
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
|
|||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
||||
|
||||
const averageScore = average(surveys.map((item) => item.score));
|
||||
const normalizeToFive = (value: CsatSurvey) => {
|
||||
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
||||
return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5));
|
||||
};
|
||||
|
||||
const averageScore = average(surveys.map((item) => normalizeToFive(item)));
|
||||
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
||||
score,
|
||||
total: surveys.filter((item) => item.score === score).length,
|
||||
total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length,
|
||||
}));
|
||||
|
||||
const positiveThreshold = 4;
|
||||
const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length;
|
||||
const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null;
|
||||
|
||||
const agentStats = new Map<
|
||||
string,
|
||||
{ id: string; name: string; total: number; sum: number; positive: number }
|
||||
>();
|
||||
|
||||
for (const survey of surveys) {
|
||||
const normalizedScore = normalizeToFive(survey);
|
||||
const key = survey.assigneeId ?? "unassigned";
|
||||
const existing = agentStats.get(key) ?? {
|
||||
id: key,
|
||||
name: survey.assigneeName ?? "Sem responsável",
|
||||
total: 0,
|
||||
sum: 0,
|
||||
positive: 0,
|
||||
};
|
||||
existing.total += 1;
|
||||
existing.sum += normalizedScore;
|
||||
if (normalizedScore >= positiveThreshold) {
|
||||
existing.positive += 1;
|
||||
}
|
||||
if (survey.assigneeName && survey.assigneeName.trim().length > 0) {
|
||||
existing.name = survey.assigneeName.trim();
|
||||
}
|
||||
agentStats.set(key, existing);
|
||||
}
|
||||
|
||||
const byAgent = Array.from(agentStats.values())
|
||||
.map((entry) => ({
|
||||
agentId: entry.id === "unassigned" ? null : entry.id,
|
||||
agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name,
|
||||
totalResponses: entry.total,
|
||||
averageScore: entry.total > 0 ? entry.sum / entry.total : null,
|
||||
positiveRate: entry.total > 0 ? entry.positive / entry.total : null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0);
|
||||
if (Math.abs(diff) > 0.0001) return diff;
|
||||
return (b.totalResponses ?? 0) - (a.totalResponses ?? 0);
|
||||
});
|
||||
|
||||
return {
|
||||
totalSurveys: surveys.length,
|
||||
averageScore,
|
||||
|
|
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
|
|||
ticketId: item.ticketId,
|
||||
reference: item.reference,
|
||||
score: item.score,
|
||||
maxScore: item.maxScore,
|
||||
comment: item.comment,
|
||||
receivedAt: item.receivedAt,
|
||||
assigneeId: item.assigneeId,
|
||||
assigneeName: item.assigneeName,
|
||||
})),
|
||||
rangeDays: days,
|
||||
positiveRate,
|
||||
byAgent,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -309,15 +475,15 @@ export async function openedResolvedByDayHandler(
|
|||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const endMs = end.getTime() + ONE_DAY_MS;
|
||||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
|
||||
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
|
||||
const opened: Record<string, number> = {}
|
||||
const resolved: Record<string, number> = {}
|
||||
|
||||
|
|
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
|
|||
resolved[key] = 0
|
||||
}
|
||||
|
||||
for (const t of tickets) {
|
||||
if (t.createdAt >= startMs && t.createdAt < endMs) {
|
||||
const key = formatDateKey(t.createdAt)
|
||||
for (const ticket of openedTickets) {
|
||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
||||
const key = formatDateKey(ticket.createdAt)
|
||||
opened[key] = (opened[key] ?? 0) + 1
|
||||
}
|
||||
if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) {
|
||||
const key = formatDateKey(t.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
for (const ticket of resolvedTickets) {
|
||||
if (typeof ticket.resolvedAt !== "number") {
|
||||
continue
|
||||
}
|
||||
const key = formatDateKey(ticket.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
const series: Array<{ date: string; opened: number; resolved: number }> = []
|
||||
|
|
|
|||
152
convex/schema.ts
152
convex/schema.ts
|
|
@ -169,6 +169,26 @@ export default defineSchema({
|
|||
})
|
||||
)
|
||||
),
|
||||
csatScore: v.optional(v.number()),
|
||||
csatMaxScore: v.optional(v.number()),
|
||||
csatComment: v.optional(v.string()),
|
||||
csatRatedAt: v.optional(v.number()),
|
||||
csatRatedBy: v.optional(v.id("users")),
|
||||
csatAssigneeId: v.optional(v.id("users")),
|
||||
csatAssigneeSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
formTemplate: v.optional(v.string()),
|
||||
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
||||
resolvedWithTicketId: v.optional(v.id("tickets")),
|
||||
reopenDeadline: v.optional(v.number()),
|
||||
reopenedAt: v.optional(v.number()),
|
||||
chatEnabled: v.optional(v.boolean()),
|
||||
totalWorkedMs: v.optional(v.number()),
|
||||
internalWorkedMs: v.optional(v.number()),
|
||||
externalWorkedMs: v.optional(v.number()),
|
||||
|
|
@ -183,7 +203,9 @@ export default defineSchema({
|
|||
.index("by_tenant_machine", ["tenantId", "machineId"])
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"])
|
||||
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"]),
|
||||
.index("by_tenant_resolved", ["tenantId", "resolvedAt"])
|
||||
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"])
|
||||
.index("by_tenant_company_resolved", ["tenantId", "companyId", "resolvedAt"]),
|
||||
|
||||
ticketComments: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
@ -221,6 +243,45 @@ export default defineSchema({
|
|||
createdAt: v.number(),
|
||||
}).index("by_ticket", ["ticketId"]),
|
||||
|
||||
ticketChatMessages: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
authorId: v.id("users"),
|
||||
authorSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
body: v.string(),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
storageId: v.id("_storage"),
|
||||
name: v.string(),
|
||||
size: v.optional(v.number()),
|
||||
type: v.optional(v.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
notifiedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
tenantId: v.string(),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
readBy: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
userId: v.id("users"),
|
||||
readAt: v.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
})
|
||||
.index("by_ticket_created", ["ticketId", "createdAt"])
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"]),
|
||||
|
||||
commentTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
kind: v.optional(v.string()),
|
||||
|
|
@ -291,11 +352,29 @@ export default defineSchema({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.index("by_tenant_scope", ["tenantId", "scope"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
ticketFormSettings: defineTable({
|
||||
tenantId: v.string(),
|
||||
template: v.string(),
|
||||
scope: v.string(), // tenant | company | user
|
||||
companyId: v.optional(v.id("companies")),
|
||||
userId: v.optional(v.id("users")),
|
||||
enabled: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_template_scope", ["tenantId", "template", "scope"])
|
||||
.index("by_tenant_template_company", ["tenantId", "template", "companyId"])
|
||||
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
userInvites: defineTable({
|
||||
|
|
@ -339,6 +418,23 @@ export default defineSchema({
|
|||
serialNumbers: v.array(v.string()),
|
||||
fingerprint: v.string(),
|
||||
metadata: v.optional(v.any()),
|
||||
displayName: v.optional(v.string()),
|
||||
deviceType: v.optional(v.string()),
|
||||
devicePlatform: v.optional(v.string()),
|
||||
deviceProfile: v.optional(v.any()),
|
||||
managementMode: v.optional(v.string()),
|
||||
customFields: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
fieldId: v.id("deviceFields"),
|
||||
fieldKey: v.string(),
|
||||
label: v.string(),
|
||||
type: v.string(),
|
||||
value: v.any(),
|
||||
displayValue: v.optional(v.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
lastHeartbeatAt: v.optional(v.number()),
|
||||
status: v.optional(v.string()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
|
|
@ -382,4 +478,58 @@ export default defineSchema({
|
|||
.index("by_tenant_machine", ["tenantId", "machineId"])
|
||||
.index("by_machine_created", ["machineId", "createdAt"])
|
||||
.index("by_machine_revoked_expires", ["machineId", "revoked", "expiresAt"]),
|
||||
|
||||
deviceFields: defineTable({
|
||||
tenantId: v.string(),
|
||||
key: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
required: v.optional(v.boolean()),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
order: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_scope", ["tenantId", "scope"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
deviceExportTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
columns: v.array(
|
||||
v.object({
|
||||
key: v.string(),
|
||||
label: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
filters: v.optional(v.any()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
isDefault: v.optional(v.boolean()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_default", ["tenantId", "isDefault"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
});
|
||||
|
|
|
|||
149
convex/ticketFormSettings.ts
Normal file
149
convex/ticketFormSettings.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
const KNOWN_TEMPLATES = new Set(["admissao", "desligamento"])
|
||||
const VALID_SCOPES = new Set(["tenant", "company", "user"])
|
||||
|
||||
function normalizeTemplate(input: string) {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!KNOWN_TEMPLATES.has(normalized)) {
|
||||
throw new ConvexError("Template desconhecido")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeScope(input: string) {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!VALID_SCOPES.has(normalized)) {
|
||||
throw new ConvexError("Escopo inválido")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
template: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, template }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const normalizedTemplate = template ? normalizeTemplate(template) : null
|
||||
const settings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
return settings
|
||||
.filter((setting) => !normalizedTemplate || setting.template === normalizedTemplate)
|
||||
.map((setting) => ({
|
||||
id: setting._id,
|
||||
template: setting.template,
|
||||
scope: setting.scope,
|
||||
companyId: setting.companyId ?? null,
|
||||
userId: setting.userId ?? null,
|
||||
enabled: setting.enabled,
|
||||
createdAt: setting.createdAt,
|
||||
updatedAt: setting.updatedAt,
|
||||
actorId: setting.actorId ?? null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
template: v.string(),
|
||||
scope: v.string(),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
userId: v.optional(v.id("users")),
|
||||
enabled: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const normalizedTemplate = normalizeTemplate(template)
|
||||
const normalizedScope = normalizeScope(scope)
|
||||
|
||||
if (normalizedScope === "company" && !companyId) {
|
||||
throw new ConvexError("Informe a empresa para configurar o template")
|
||||
}
|
||||
if (normalizedScope === "user" && !userId) {
|
||||
throw new ConvexError("Informe o usuário para configurar o template")
|
||||
}
|
||||
if (normalizedScope === "tenant") {
|
||||
if (companyId || userId) {
|
||||
throw new ConvexError("Escopo global não aceita empresa ou usuário")
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await findExisting(ctx, tenantId, normalizedTemplate, normalizedScope, companyId, userId)
|
||||
const now = Date.now()
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
enabled,
|
||||
updatedAt: now,
|
||||
actorId,
|
||||
})
|
||||
return existing._id
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("ticketFormSettings", {
|
||||
tenantId,
|
||||
template: normalizedTemplate,
|
||||
scope: normalizedScope,
|
||||
companyId: normalizedScope === "company" ? (companyId as Id<"companies">) : undefined,
|
||||
userId: normalizedScope === "user" ? (userId as Id<"users">) : undefined,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
actorId,
|
||||
})
|
||||
return id
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
settingId: v.id("ticketFormSettings"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, settingId }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const setting = await ctx.db.get(settingId)
|
||||
if (!setting || setting.tenantId !== tenantId) {
|
||||
throw new ConvexError("Configuração não encontrada")
|
||||
}
|
||||
await ctx.db.delete(settingId)
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
async function findExisting(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
tenantId: string,
|
||||
template: string,
|
||||
scope: string,
|
||||
companyId?: Id<"companies">,
|
||||
userId?: Id<"users">,
|
||||
) {
|
||||
const candidates = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", template).eq("scope", scope))
|
||||
.collect()
|
||||
|
||||
return candidates.find((setting) => {
|
||||
if (scope === "tenant") return true
|
||||
if (scope === "company") {
|
||||
return setting.companyId && companyId && String(setting.companyId) === String(companyId)
|
||||
}
|
||||
if (scope === "user") {
|
||||
return setting.userId && userId && String(setting.userId) === String(userId)
|
||||
}
|
||||
return false
|
||||
}) ?? null
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue