feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -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;
}>;

View 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
View 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
View file

@ -0,0 +1 @@
export * from "./machines"

View file

@ -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(),
});
},

View file

@ -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

View file

@ -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 }> = []

View file

@ -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"]),
});

View 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