diff --git a/README.md b/README.md
index 18131a3..faad514 100644
--- a/README.md
+++ b/README.md
@@ -68,13 +68,13 @@ Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prism
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
-## Transferir máquina entre colaboradores
+## Transferir dispositivo entre colaboradores
-Quando uma máquina trocar de responsável:
+Quando uma dispositivo trocar de responsável:
-1. Abra `Admin > Máquinas`, selecione o equipamento e clique em **Resetar agente**.
+1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**.
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
-3. Após o agente gerar um novo token, associe a máquina ao novo colaborador no painel.
+3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel.
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
@@ -98,7 +98,7 @@ Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso
-## Diagnóstico de sessão da máquina (Desktop)
+## Diagnóstico de sessão da dispositivo (Desktop)
- Quando o portal for aberto via app desktop, use a página `https://seu-app/portal/debug` para validar cookies e contexto:
- `/api/auth/get-session` deve idealmente mostrar `user.role = "machine"` (em alguns ambientes WebView pode retornar `null`, o que não é bloqueante).
diff --git a/agents.md b/agents.md
index 5bb4ec7..85d9646 100644
--- a/agents.md
+++ b/agents.md
@@ -67,19 +67,19 @@ pnpm build
- `pnpm -C apps/desktop tauri dev` — desenvolvimento (porta 1420).
- `pnpm -C apps/desktop tauri build` — gera instaladores.
- **Fluxo do agente**:
- 1. Coleta perfil da máquina (hostname, OS, MAC, seriais, métricas).
+ 1. Coleta perfil da dispositivo (hostname, OS, MAC, seriais, métricas).
2. Provisiona via `POST /api/machines/register` usando `MACHINE_PROVISIONING_SECRET`, informando perfil de acesso (Colaborador/Gestor) + dados do colaborador.
3. Envia heartbeats periódicos (`/api/machines/heartbeat`) com inventário básico + estendido (discos SMART, GPUs, serviços, softwares, CPU window).
4. Realiza handshake em `APP_URL/machines/handshake?token=...&redirect=...` para receber cookies Better Auth + sessão (colaborador → `/portal`, gestor → `/dashboard`).
5. Token persistido no cofre do SO (Keyring); store guarda apenas metadados.
6. Envio manual de inventário via botão (POST `/api/machines/inventory`).
7. Updates automáticos: plugin `@tauri-apps/plugin-updater` consulta `latest.json` publicado nos releases do GitHub.
-- **Admin ▸ Máquinas**: permite ajustar perfil/email associado, visualizar inventário completo e remover máquina.
+- **Admin ▸ Dispositivos**: permite ajustar perfil/email associado, visualizar inventário completo e remover dispositivo.
### Sessão "machine" no frontend
-- Ao autenticar como máquina, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
+- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
-- UI remove opção "Sair" no menu do usuário quando detecta sessão de máquina.
+- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
### Observações adicionais
@@ -116,7 +116,7 @@ pnpm build
## Estado do portal / app web
- Autenticação Better Auth com `AuthGuard`.
-- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de máquina).
+- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
@@ -126,7 +126,7 @@ pnpm build
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
- Admin > Usuários/Equipe:
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
- - Multi‑seleção + ações em massa: excluir usuários, remover agentes de máquina e revogar convites pendentes.
+ - Multi‑seleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
- Admin > Usuários: vincular colaborador à empresa.
@@ -137,7 +137,7 @@ pnpm build
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
-- **Sessão Máquina**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
+- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
### Correções recentes
- Temporizador do ticket (atendimento em andamento): a UI passa a aplicar atualização otimista na abertura/pausa da sessão para que o tempo corrente não "salte" para minutos indevidos. O back‑end continua a fonte da verdade (total acumulado é reconciliado ao pausar).
@@ -160,7 +160,7 @@ pnpm build
- CSAT: `/api/reports/csat.xlsx?...`
- SLA: `/api/reports/sla.xlsx?...`
- Horas: `/api/reports/hours-by-client.xlsx?...`
- - Inventário de máquinas: `/api/reports/machines-inventory.xlsx?[companyId=...]`
+ - Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
- **Docs complementares**:
- `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index ffe4d4e..fd7c3f7 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -1,11 +1,11 @@
# Sistema de Chamados — App Desktop (Tauri)
Cliente desktop (Tauri v2 + Vite) que:
-- Coleta perfil/métricas da máquina via comandos Rust.
-- Registra a máquina com um código de provisionamento.
+- Coleta perfil/métricas da dispositivo via comandos Rust.
+- Registra a dispositivo com um código de provisionamento.
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
- Redireciona para a UI web do sistema após provisionamento.
-- Armazena o token da máquina com segurança no cofre do SO (Keyring).
+- Armazena o token da dispositivo com segurança no cofre do SO (Keyring).
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
## URLs e ambiente
@@ -65,7 +65,7 @@ pnpm -C apps/desktop tauri build --bundles nsis
Consulte https://tauri.app/start/prerequisites/
## Fluxo (resumo)
-1) Ao abrir, o app coleta o perfil da máquina e exibe um resumo.
+1) Ao abrir, o app coleta o perfil da dispositivo e exibe um resumo.
2) Informe o “código de provisionamento” (chave definida no servidor) e confirme.
3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat.
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.
diff --git a/apps/desktop/docs/guia-ci-cd-web-desktop.md b/apps/desktop/docs/guia-ci-cd-web-desktop.md
index 7b0a8b1..5ef09e2 100644
--- a/apps/desktop/docs/guia-ci-cd-web-desktop.md
+++ b/apps/desktop/docs/guia-ci-cd-web-desktop.md
@@ -159,7 +159,7 @@
## 5. Gerar chaves do updater Tauri
-1. Em qualquer máquina com Node/pnpm (pode ser seu computador local):
+1. Em qualquer dispositivo com Node/pnpm (pode ser seu computador local):
```bash
pnpm install
pnpm --filter appsdesktop tauri signer generate
@@ -267,10 +267,10 @@
.\svc start
```
6. Confirme no GitHub que o runner aparece como `online`.
-7. Mantenha a máquina ligada e conectada durante o período em que o workflow precisa rodar:
+7. Mantenha a dispositivo ligada e conectada durante o período em que o workflow precisa rodar:
- Para releases desktop, o runner só precisa estar ligado enquanto o job `desktop_release` estiver em execução (crie a tag e aguarde o workflow terminar).
- Após a conclusão, você pode desligar o computador até a próxima release.
-8. Observação importante: o runner Windows pode ser sua máquina pessoal. Garanta apenas que:
+8. Observação importante: o runner Windows pode ser sua dispositivo pessoal. Garanta apenas que:
- Você confia no código que será executado (o runner processa os jobs do repositório).
- O serviço do runner esteja ativo enquanto o workflow rodar (caso desligue o PC, as releases ficam na fila).
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
@@ -429,7 +429,7 @@
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
4. Manter runners:
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
- - Windows: mantenha máquina ligada e atualizada. Se o serviço parar, abra `services.msc` → `GitHub Actions Runner` → Start.
+ - Windows: mantenha dispositivo ligada e atualizada. Se o serviço parar, abra `services.msc` → `GitHub Actions Runner` → Start.
---
@@ -451,7 +451,7 @@
| Job `desktop_release` falha na etapa `tauri-action` | Toolchain incompleto no Windows | Reinstale Rust, WebView2 e componentes C++ do Visual Studio. |
| Artefatos não chegam à VPS | Caminho incorreto ou chave SSH inválida | Verifique `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` e se a pasta `/var/www/updates` existe. |
| App não encontra update | URL ou chave pública divergente no `tauri.conf.json` | Confirme que `endpoints` bate com o domínio HTTPS e que `pubkey` é exatamente a chave pública gerada. |
-| Runner aparece offline no GitHub | Serviço parado ou máquina desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
+| Runner aparece offline no GitHub | Serviço parado ou dispositivo desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
---
diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs
index 3066157..67ddf75 100644
--- a/apps/desktop/src-tauri/src/agent.rs
+++ b/apps/desktop/src-tauri/src/agent.rs
@@ -13,7 +13,7 @@ use tokio::sync::Notify;
#[derive(thiserror::Error, Debug)]
pub enum AgentError {
- #[error("Falha ao obter hostname da máquina")]
+ #[error("Falha ao obter hostname da dispositivo")]
Hostname,
#[error("Nenhum identificador de hardware disponível (MAC/serial)")]
MissingIdentifiers,
diff --git a/apps/desktop/src/components/DeactivationScreen.tsx b/apps/desktop/src/components/DeactivationScreen.tsx
index 2965f51..972a0ea 100644
--- a/apps/desktop/src/components/DeactivationScreen.tsx
+++ b/apps/desktop/src/components/DeactivationScreen.tsx
@@ -8,9 +8,9 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
Acesso bloqueado
-
Máquina desativada
+
Dispositivo desativada
- Esta máquina foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
+ Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
envio de informações ficam indisponíveis.
{companyName ? (
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index 4d71489..e4bf0b4 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -273,9 +273,9 @@ function App() {
const text = await res.text()
const msg = text.toLowerCase()
const isInvalid =
- msg.includes("token de máquina inválido") ||
- msg.includes("token de máquina revogado") ||
- msg.includes("token de máquina expirado")
+ msg.includes("token de dispositivo inválido") ||
+ msg.includes("token de dispositivo revogado") ||
+ msg.includes("token de dispositivo expirado")
if (isInvalid) {
try {
await store.delete("token"); await store.delete("config"); await store.save()
@@ -293,7 +293,7 @@ function App() {
} catch {}
} else {
// Não limpa token em falhas genéricas (ex.: rede); apenas informa
- setError("Falha ao validar sessão da máquina. Tente novamente.")
+ setError("Falha ao validar sessão da dispositivo. Tente novamente.")
tokenVerifiedRef.current = true
setTokenValidationTick((tick) => tick + 1)
}
@@ -443,12 +443,12 @@ function App() {
return
}
if (!validatedCompany) {
- setError("Valide o código de provisionamento antes de registrar a máquina.")
+ setError("Valide o código de provisionamento antes de registrar a dispositivo.")
return
}
const normalizedEmail = collabEmail.trim().toLowerCase()
if (!normalizedEmail) {
- setError("Informe o e-mail do colaborador vinculado a esta máquina.")
+ setError("Informe o e-mail do colaborador vinculado a esta dispositivo.")
return
}
if (!emailRegex.current.test(normalizedEmail)) {
@@ -575,7 +575,7 @@ function App() {
setError(null)
}
if (!currentActive) {
- setError("Esta máquina está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
+ setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
setIsLaunchingSystem(false)
return
}
@@ -590,7 +590,7 @@ function App() {
: ""
setIsMachineActive(false)
setIsLaunchingSystem(false)
- setError(message.length > 0 ? message : "Esta máquina está desativada. Entre em contato com o suporte da Rever.")
+ setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
return
}
// Se sessão falhar, tenta identificar token inválido/expirado
@@ -604,9 +604,9 @@ function App() {
const text = await hb.text()
const low = text.toLowerCase()
const invalid =
- low.includes("token de máquina inválido") ||
- low.includes("token de máquina revogado") ||
- low.includes("token de máquina expirado")
+ low.includes("token de dispositivo inválido") ||
+ low.includes("token de dispositivo revogado") ||
+ low.includes("token de dispositivo expirado")
if (invalid) {
// Força onboarding
await store?.delete("token"); await store?.delete("config"); await store?.save()
@@ -616,7 +616,7 @@ function App() {
setConfig(null)
setStatus(null)
setIsMachineActive(true)
- setError("Sessão expirada. Reprovisione a máquina para continuar.")
+ setError("Sessão expirada. Reprovisione a dispositivo para continuar.")
setIsLaunchingSystem(false)
const p = await invoke("collect_machine_profile")
setProfile(p)
@@ -787,7 +787,7 @@ function App() {
{error ?
{error}
: null}
{!token ? (
-
Informe os dados para registrar esta máquina.
+
Informe os dados para registrar esta dispositivo.
@@ -822,7 +822,7 @@ function App() {
) : (
- Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa.
+ Informe o código único fornecido pela equipe para vincular esta dispositivo a uma empresa.
)}
@@ -832,7 +832,7 @@ function App() {
{validatedCompany.name}
- Código reconhecido. Esta máquina será vinculada automaticamente à empresa informada.
+ Código reconhecido. Esta dispositivo será vinculada automaticamente à empresa informada.
@@ -884,7 +884,7 @@ function App() {
) : null}
-
+
) : (
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 5d6b9d2..e842232 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -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;
}>;
diff --git a/convex/deviceExportTemplates.ts b/convex/deviceExportTemplates.ts
new file mode 100644
index 0000000..0e11179
--- /dev/null
+++ b/convex/deviceExportTemplates.ts
@@ -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,
+ })
+ },
+})
diff --git a/convex/deviceFields.ts b/convex/deviceFields.ts
new file mode 100644
index 0000000..d49aa60
--- /dev/null
+++ b/convex/deviceFields.ts
@@ -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,
+ })
+ )
+ )
+ },
+})
diff --git a/convex/devices.ts b/convex/devices.ts
new file mode 100644
index 0000000..d01664f
--- /dev/null
+++ b/convex/devices.ts
@@ -0,0 +1 @@
+export * from "./machines"
diff --git a/convex/fields.ts b/convex/fields.ts
index fa13cb5..c4b62ff 100644
--- a/convex/fields.ts
+++ b/convex/fields.ts
@@ -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(),
});
},
diff --git a/convex/machines.ts b/convex/machines.ts
index bb0845f..68ed346 100644
--- a/convex/machines.ts
+++ b/convex/machines.ts
@@ -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
@@ -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>(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
diff --git a/convex/reports.ts b/convex/reports.ts
index 6dcfc4f..793bc48 100644
--- a/convex/reports.ts
+++ b/convex/reports.ts
@@ -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
+ 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(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>,
+ 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 {
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 = {}
const resolved: Record = {}
@@ -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 }> = []
diff --git a/convex/schema.ts b/convex/schema.ts
index d11344a..ef473e2 100644
--- a/convex/schema.ts
+++ b/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"]),
});
diff --git a/convex/ticketFormSettings.ts b/convex/ticketFormSettings.ts
new file mode 100644
index 0000000..20fa07e
--- /dev/null
+++ b/convex/ticketFormSettings.ts
@@ -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
+}
diff --git a/convex/tickets.ts b/convex/tickets.ts
index 12cc446..b3da0e0 100644
--- a/convex/tickets.ts
+++ b/convex/tickets.ts
@@ -41,6 +41,23 @@ const missingCommentAuthorLogCache = new Set();
// Character limits (generous but bounded)
const MAX_SUMMARY_CHARS = 600;
const MAX_COMMENT_CHARS = 20000;
+const DEFAULT_REOPEN_DAYS = 7;
+const MAX_REOPEN_DAYS = 14;
+
+const TICKET_FORM_CONFIG = [
+ {
+ key: "admissao" as const,
+ label: "Admissão de colaborador",
+ description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.",
+ defaultEnabled: true,
+ },
+ {
+ key: "desligamento" as const,
+ label: "Desligamento de colaborador",
+ description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.",
+ defaultEnabled: true,
+ },
+];
function plainTextLength(html: string): number {
try {
@@ -63,6 +80,110 @@ function escapeHtml(input: string): string {
.replace(/'/g, "'");
}
+function normalizeFormTemplateKey(input: string | null | undefined): string | null {
+ if (!input) return null;
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+ const normalized = trimmed
+ .normalize("NFD")
+ .replace(/[^\w\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+ .toLowerCase();
+ return normalized || null;
+}
+
+function resolveReopenWindowDays(input?: number | null): number {
+ if (typeof input !== "number" || !Number.isFinite(input)) {
+ return DEFAULT_REOPEN_DAYS;
+ }
+ const rounded = Math.round(input);
+ if (rounded < 1) return 1;
+ if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS;
+ return rounded;
+}
+
+function computeReopenDeadline(now: number, windowDays: number): number {
+ return now + windowDays * 24 * 60 * 60 * 1000;
+}
+
+function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null {
+ if (typeof ticket.reopenDeadline === "number") {
+ return ticket.reopenDeadline;
+ }
+ if (typeof ticket.closedAt === "number") {
+ return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
+ }
+ if (typeof ticket.resolvedAt === "number") {
+ return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
+ }
+ return null;
+}
+
+function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean {
+ const deadline = inferExistingReopenDeadline(ticket);
+ if (!deadline) {
+ return true;
+ }
+ return now <= deadline;
+}
+
+function findLatestSetting(entries: T[], predicate: (entry: T) => boolean): T | null {
+ let latest: T | null = null;
+ for (const entry of entries) {
+ if (!predicate(entry)) continue;
+ if (!latest || entry.updatedAt > latest.updatedAt) {
+ latest = entry;
+ }
+ }
+ return latest;
+}
+
+function resolveFormEnabled(
+ template: string,
+ baseEnabled: boolean,
+ settings: Doc<"ticketFormSettings">[],
+ context: { companyId?: Id<"companies"> | null; userId: Id<"users"> }
+): boolean {
+ const scoped = settings.filter((setting) => setting.template === template)
+ if (scoped.length === 0) {
+ return baseEnabled
+ }
+ const userSetting = findLatestSetting(scoped, (setting) => {
+ if (setting.scope !== "user") {
+ return false
+ }
+ if (!setting.userId) {
+ return false
+ }
+ return String(setting.userId) === String(context.userId)
+ })
+ if (userSetting) {
+ return userSetting.enabled ?? baseEnabled
+ }
+ const companyId = context.companyId ? String(context.companyId) : null
+ if (companyId) {
+ const companySetting = findLatestSetting(scoped, (setting) => {
+ if (setting.scope !== "company") {
+ return false
+ }
+ if (!setting.companyId) {
+ return false
+ }
+ return String(setting.companyId) === companyId
+ })
+ if (companySetting) {
+ return companySetting.enabled ?? baseEnabled
+ }
+ }
+ const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant")
+ if (tenantSetting) {
+ return tenantSetting.enabled ?? baseEnabled
+ }
+ return baseEnabled
+}
+
export function buildAssigneeChangeComment(
reason: string,
context: { previousName: string; nextName: string },
@@ -313,6 +434,35 @@ async function requireTicketStaff(
return viewer
}
+type TicketChatParticipant = {
+ user: Doc<"users">;
+ role: string | null;
+ kind: "staff" | "manager" | "requester";
+};
+
+async function requireTicketChatParticipant(
+ ctx: MutationCtx | QueryCtx,
+ actorId: Id<"users">,
+ ticket: Doc<"tickets">
+): Promise {
+ const viewer = await requireUser(ctx, actorId, ticket.tenantId);
+ const normalizedRole = viewer.role ?? "";
+ if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") {
+ return { user: viewer.user, role: normalizedRole, kind: "staff" };
+ }
+ if (normalizedRole === "MANAGER") {
+ await ensureManagerTicketAccess(ctx, viewer.user, ticket);
+ return { user: viewer.user, role: normalizedRole, kind: "manager" };
+ }
+ if (normalizedRole === "COLLABORATOR") {
+ if (String(ticket.requesterId) !== String(viewer.user._id)) {
+ throw new ConvexError("Apenas o solicitante pode conversar neste chamado");
+ }
+ return { user: viewer.user, role: normalizedRole, kind: "requester" };
+ }
+ throw new ConvexError("Usuário não possui acesso ao chat deste chamado");
+}
+
const QUEUE_RENAME_LOOKUP: Record = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
@@ -590,16 +740,29 @@ function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { val
async function normalizeCustomFieldValues(
ctx: Pick,
tenantId: string,
- inputs: CustomFieldInput[] | undefined
+ inputs: CustomFieldInput[] | undefined,
+ scope?: string | null
): Promise {
+ const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null;
const definitions = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
- if (!definitions.length) {
+ const scopedDefinitions = definitions.filter((definition) => {
+ const fieldScope = (definition.scope ?? "all").toLowerCase();
+ if (fieldScope === "all" || fieldScope.length === 0) {
+ return true;
+ }
+ if (!normalizedScope) {
+ return false;
+ }
+ return fieldScope === normalizedScope;
+ });
+
+ if (!scopedDefinitions.length) {
if (inputs && inputs.length > 0) {
- throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
+ throw new ConvexError("Campos personalizados não configurados para este formulário");
}
return [];
}
@@ -611,7 +774,7 @@ async function normalizeCustomFieldValues(
const normalized: NormalizedCustomField[] = [];
- for (const definition of definitions.sort((a, b) => a.order - b.order)) {
+ for (const definition of scopedDefinitions.sort((a, b) => a.order - b.order)) {
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
const isMissing =
raw === undefined ||
@@ -1181,6 +1344,11 @@ export const getById = query({
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
+ csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
+ csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
+ csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
+ csatRatedAt: t.csatRatedAt ?? null,
+ csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
machine: machineSummary,
category: category
? {
@@ -1218,6 +1386,12 @@ export const getById = query({
externalWorkedMs: item.externalWorkedMs,
})),
},
+ formTemplate: t.formTemplate ?? null,
+ chatEnabled: Boolean(t.chatEnabled),
+ relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
+ resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
+ reopenDeadline: t.reopenDeadline ?? null,
+ reopenedAt: t.reopenedAt ?? null,
description: undefined,
customFields: customFieldsRecord,
timeline: timelineRecords.map((ev) => {
@@ -1262,6 +1436,8 @@ export const create = mutation({
})
)
),
+ formTemplate: v.optional(v.string()),
+ chatEnabled: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
@@ -1323,12 +1499,19 @@ export const create = mutation({
if (args.machineId) {
const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
if (!machine || machine.tenantId !== args.tenantId) {
- throw new ConvexError("Máquina inválida para este chamado")
+ throw new ConvexError("Dispositivo inválida para este chamado")
}
machineDoc = machine
}
- const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
+ const formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
+ const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
+ const normalizedCustomFields = await normalizeCustomFieldValues(
+ ctx,
+ args.tenantId,
+ args.customFields ?? undefined,
+ formTemplateKey,
+ );
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
@@ -1404,6 +1587,8 @@ export const create = mutation({
status: machineDoc.status ?? undefined,
}
: undefined,
+ formTemplate: formTemplateKey ?? undefined,
+ chatEnabled,
working: false,
activeSessionId: undefined,
totalWorkedMs: 0,
@@ -1703,12 +1888,201 @@ export const updateStatus = mutation({
},
});
+export async function resolveTicketHandler(
+ ctx: MutationCtx,
+ { ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: {
+ ticketId: Id<"tickets">
+ actorId: Id<"users">
+ resolvedWithTicketId?: Id<"tickets">
+ relatedTicketIds?: Id<"tickets">[]
+ reopenWindowDays?: number | null
+ }
+) {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+ const ticketDoc = ticket as Doc<"tickets">
+ const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
+ const now = Date.now()
+
+ const baseRelated = new Set()
+ for (const rel of relatedTicketIds ?? []) {
+ if (String(rel) === String(ticketId)) continue
+ baseRelated.add(String(rel))
+ }
+ if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) {
+ baseRelated.add(String(resolvedWithTicketId))
+ }
+
+ const linkedTickets: Doc<"tickets">[] = []
+ for (const id of baseRelated) {
+ const related = await ctx.db.get(id as Id<"tickets">)
+ if (!related) continue
+ if (related.tenantId !== ticketDoc.tenantId) {
+ throw new ConvexError("Chamado vinculado pertence a outro tenant")
+ }
+ linkedTickets.push(related as Doc<"tickets">)
+ }
+
+ const resolvedWith =
+ resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)
+ ? (await ctx.db.get(resolvedWithTicketId)) ?? null
+ : null
+ if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) {
+ throw new ConvexError("Chamado vinculado pertence a outro tenant")
+ }
+ if (resolvedWithTicketId && !resolvedWith) {
+ throw new ConvexError("Chamado vinculado não encontrado")
+ }
+
+ const reopenDays = resolveReopenWindowDays(reopenWindowDays)
+ const reopenDeadline = computeReopenDeadline(now, reopenDays)
+ const normalizedStatus = "RESOLVED"
+ const relatedIdList = Array.from(
+ new Set(
+ linkedTickets.map((rel) => String(rel._id)),
+ ),
+ ).map((id) => id as Id<"tickets">)
+
+ await ctx.db.patch(ticketId, {
+ status: normalizedStatus,
+ resolvedAt: now,
+ closedAt: now,
+ updatedAt: now,
+ reopenDeadline,
+ reopenedAt: undefined,
+ resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined,
+ relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
+ activeSessionId: undefined,
+ working: false,
+ })
+
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "STATUS_CHANGED",
+ payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
+ createdAt: now,
+ })
+
+ for (const rel of linkedTickets) {
+ const existing = new Set((rel.relatedTicketIds ?? []).map((value) => String(value)))
+ existing.add(String(ticketId))
+ await ctx.db.patch(rel._id, {
+ relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">),
+ updatedAt: now,
+ })
+ const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related"
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "TICKET_LINKED",
+ payload: {
+ actorId,
+ actorName: viewer.user.name,
+ linkedTicketId: rel._id,
+ linkedReference: rel.reference ?? null,
+ linkedSubject: rel.subject ?? null,
+ kind: linkKind,
+ },
+ createdAt: now,
+ })
+ await ctx.db.insert("ticketEvents", {
+ ticketId: rel._id,
+ type: "TICKET_LINKED",
+ payload: {
+ actorId,
+ actorName: viewer.user.name,
+ linkedTicketId: ticketId,
+ linkedReference: ticketDoc.reference ?? null,
+ linkedSubject: ticketDoc.subject ?? null,
+ kind: linkKind === "resolved_with" ? "resolution_parent" : "related",
+ },
+ createdAt: now,
+ })
+ }
+
+ return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
+}
+
+export const resolveTicket = mutation({
+ args: {
+ ticketId: v.id("tickets"),
+ actorId: v.id("users"),
+ resolvedWithTicketId: v.optional(v.id("tickets")),
+ relatedTicketIds: v.optional(v.array(v.id("tickets"))),
+ reopenWindowDays: v.optional(v.number()),
+ },
+ handler: resolveTicketHandler,
+})
+
+export async function reopenTicketHandler(
+ ctx: MutationCtx,
+ { ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> }
+) {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+ const ticketDoc = ticket as Doc<"tickets">
+ const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId)
+ const normalizedRole = viewer.role ?? ""
+ const now = Date.now()
+ const status = normalizeStatus(ticketDoc.status)
+ if (status !== "RESOLVED") {
+ throw new ConvexError("Somente chamados resolvidos podem ser reabertos")
+ }
+ if (!isWithinReopenWindow(ticketDoc, now)) {
+ throw new ConvexError("O prazo para reabrir este chamado expirou")
+ }
+ if (normalizedRole === "COLLABORATOR") {
+ if (String(ticketDoc.requesterId) !== String(actorId)) {
+ throw new ConvexError("Somente o solicitante pode reabrir este chamado")
+ }
+ } else if (normalizedRole === "MANAGER") {
+ await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc)
+ } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
+ throw new ConvexError("Usuário não possui permissão para reabrir este chamado")
+ }
+
+ await ctx.db.patch(ticketId, {
+ status: "AWAITING_ATTENDANCE",
+ reopenedAt: now,
+ resolvedAt: undefined,
+ closedAt: undefined,
+ updatedAt: now,
+ })
+
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "TICKET_REOPENED",
+ payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole },
+ createdAt: now,
+ })
+
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "STATUS_CHANGED",
+ payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId },
+ createdAt: now,
+ })
+
+ return { ok: true, reopenedAt: now }
+}
+
+export const reopenTicket = mutation({
+ args: {
+ ticketId: v.id("tickets"),
+ actorId: v.id("users"),
+ },
+ handler: reopenTicketHandler,
+})
+
export const changeAssignee = mutation({
args: {
ticketId: v.id("tickets"),
assigneeId: v.id("users"),
actorId: v.id("users"),
- reason: v.string(),
+ reason: v.optional(v.string()),
},
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
const ticket = await ctx.db.get(ticketId)
@@ -1735,9 +2109,9 @@ export const changeAssignee = mutation({
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
}
- const normalizedReason = reason.replace(/\r\n/g, "\n").trim()
- if (normalizedReason.length < 5) {
- throw new ConvexError("Informe um motivo para registrar a troca de responsável")
+ const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim()
+ if (normalizedReason.length > 0 && normalizedReason.length < 5) {
+ throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco")
}
if (normalizedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
@@ -1746,14 +2120,6 @@ export const changeAssignee = mutation({
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
"Não atribuído"
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
- const commentBody = buildAssigneeChangeComment(normalizedReason, {
- previousName: previousAssigneeName,
- nextName: nextAssigneeName,
- })
- const commentPlainLength = plainTextLength(commentBody)
- if (commentPlainLength > MAX_COMMENT_CHARS) {
- throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
- }
const now = Date.now();
const assigneeSnapshot = {
@@ -1772,36 +2138,491 @@ export const changeAssignee = mutation({
actorId,
previousAssigneeId: currentAssigneeId,
previousAssigneeName,
- reason: normalizedReason,
+ reason: normalizedReason.length > 0 ? normalizedReason : undefined,
},
createdAt: now,
});
- const authorSnapshot: CommentAuthorSnapshot = {
- name: viewerUser.name,
- email: viewerUser.email,
- avatarUrl: viewerUser.avatarUrl ?? undefined,
- teams: viewerUser.teams ?? undefined,
+ if (normalizedReason.length > 0) {
+ const commentBody = buildAssigneeChangeComment(normalizedReason, {
+ previousName: previousAssigneeName,
+ nextName: nextAssigneeName,
+ })
+ const commentPlainLength = plainTextLength(commentBody)
+ if (commentPlainLength > MAX_COMMENT_CHARS) {
+ throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
+ }
+ const authorSnapshot: CommentAuthorSnapshot = {
+ name: viewerUser.name,
+ email: viewerUser.email,
+ avatarUrl: viewerUser.avatarUrl ?? undefined,
+ teams: viewerUser.teams ?? undefined,
+ }
+ await ctx.db.insert("ticketComments", {
+ ticketId,
+ authorId: actorId,
+ visibility: "INTERNAL",
+ body: commentBody,
+ authorSnapshot,
+ attachments: [],
+ createdAt: now,
+ updatedAt: now,
+ })
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "COMMENT_ADDED",
+ payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
+ createdAt: now,
+ })
}
- await ctx.db.insert("ticketComments", {
- ticketId,
- authorId: actorId,
- visibility: "INTERNAL",
- body: commentBody,
- authorSnapshot,
- attachments: [],
- createdAt: now,
- updatedAt: now,
- })
- await ctx.db.insert("ticketEvents", {
- ticketId,
- type: "COMMENT_ADDED",
- payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
- createdAt: now,
- })
},
});
+export const listChatMessages = query({
+ args: {
+ ticketId: v.id("tickets"),
+ viewerId: v.id("users"),
+ },
+ handler: async (ctx, { ticketId, viewerId }) => {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+ const ticketDoc = ticket as Doc<"tickets">
+ await requireTicketChatParticipant(ctx, viewerId, ticketDoc)
+ const now = Date.now()
+ const status = normalizeStatus(ticketDoc.status)
+ const chatEnabled = Boolean(ticketDoc.chatEnabled)
+ const withinWindow = isWithinReopenWindow(ticketDoc, now)
+ const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow)
+ const messages = await ctx.db
+ .query("ticketChatMessages")
+ .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
+ .collect()
+
+ return {
+ ticketId: String(ticketId),
+ chatEnabled,
+ status,
+ canPost,
+ reopenDeadline: ticketDoc.reopenDeadline ?? null,
+ messages: messages
+ .sort((a, b) => a.createdAt - b.createdAt)
+ .map((message) => ({
+ id: message._id,
+ body: message.body,
+ createdAt: message.createdAt,
+ updatedAt: message.updatedAt,
+ authorId: String(message.authorId),
+ authorName: message.authorSnapshot?.name ?? null,
+ authorEmail: message.authorSnapshot?.email ?? null,
+ attachments: (message.attachments ?? []).map((attachment) => ({
+ storageId: attachment.storageId,
+ name: attachment.name,
+ size: attachment.size ?? null,
+ type: attachment.type ?? null,
+ })),
+ readBy: (message.readBy ?? []).map((entry) => ({
+ userId: String(entry.userId),
+ readAt: entry.readAt,
+ })),
+ })),
+ }
+ },
+})
+
+export const listTicketForms = query({
+ args: {
+ tenantId: v.string(),
+ viewerId: v.id("users"),
+ companyId: v.optional(v.id("companies")),
+ },
+ handler: async (ctx, { tenantId, viewerId, companyId }) => {
+ const viewer = await requireUser(ctx, viewerId, tenantId)
+ const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
+
+ const settings = await ctx.db
+ .query("ticketFormSettings")
+ .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
+ .collect()
+
+ const fieldDefinitions = await ctx.db
+ .query("ticketFields")
+ .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
+ .collect()
+
+ const fieldsByScope = new Map[]>()
+ for (const definition of fieldDefinitions) {
+ const scope = (definition.scope ?? "all").trim()
+ if (!fieldsByScope.has(scope)) {
+ fieldsByScope.set(scope, [])
+ }
+ fieldsByScope.get(scope)!.push(definition)
+ }
+
+ const forms = [] as Array<{
+ key: string
+ label: string
+ description: string
+ fields: Array<{
+ id: Id<"ticketFields">
+ key: string
+ label: string
+ type: string
+ required: boolean
+ description: string
+ options: { value: string; label: string }[]
+ }>
+ }>
+
+ for (const template of TICKET_FORM_CONFIG) {
+ const enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
+ companyId: viewerCompanyId,
+ userId: viewer.user._id,
+ })
+ if (!enabled) {
+ continue
+ }
+ const scopedFields = fieldsByScope.get(template.key) ?? []
+ forms.push({
+ key: template.key,
+ label: template.label,
+ description: template.description,
+ fields: scopedFields
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ .map((field) => ({
+ id: field._id,
+ key: field.key,
+ label: field.label,
+ type: field.type,
+ required: Boolean(field.required),
+ description: field.description ?? "",
+ options: field.options ?? [],
+ })),
+ })
+ }
+
+ return forms
+ },
+})
+
+export const findByReference = query({
+ args: {
+ tenantId: v.string(),
+ viewerId: v.id("users"),
+ reference: v.number(),
+ },
+ handler: async (ctx, { tenantId, viewerId, reference }) => {
+ const viewer = await requireUser(ctx, viewerId, tenantId)
+ const ticket = await ctx.db
+ .query("tickets")
+ .withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference))
+ .first()
+ if (!ticket) {
+ return null
+ }
+ const normalizedRole = viewer.role ?? ""
+ if (normalizedRole === "MANAGER") {
+ await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">)
+ } else if (normalizedRole === "COLLABORATOR") {
+ if (String(ticket.requesterId) !== String(viewer.user._id)) {
+ return null
+ }
+ } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
+ return null
+ }
+ return {
+ id: ticket._id,
+ reference: ticket.reference,
+ subject: ticket.subject,
+ status: ticket.status,
+ }
+ },
+})
+
+export const postChatMessage = mutation({
+ args: {
+ ticketId: v.id("tickets"),
+ actorId: v.id("users"),
+ 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()),
+ })
+ )
+ ),
+ },
+ handler: async (ctx, { ticketId, actorId, body, attachments }) => {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+ const ticketDoc = ticket as Doc<"tickets">
+ if (!ticketDoc.chatEnabled) {
+ throw new ConvexError("Chat não habilitado para este chamado")
+ }
+ const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc)
+ const now = Date.now()
+ if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") {
+ throw new ConvexError("O chat deste chamado está encerrado")
+ }
+
+ const trimmedBody = body.replace(/\r\n/g, "\n").trim()
+ if (trimmedBody.length === 0) {
+ throw new ConvexError("Digite uma mensagem para enviar no chat")
+ }
+ if (trimmedBody.length > 4000) {
+ throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
+ }
+
+ const files = attachments ?? []
+ if (files.length > 5) {
+ throw new ConvexError("Envie até 5 arquivos por mensagem")
+ }
+ const maxAttachmentSize = 5 * 1024 * 1024
+ for (const file of files) {
+ if (typeof file.size === "number" && file.size > maxAttachmentSize) {
+ throw new ConvexError("Cada arquivo pode ter até 5MB")
+ }
+ }
+
+ const normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId)
+ const plainLength = plainTextLength(normalizedBody)
+ if (plainLength === 0) {
+ throw new ConvexError("A mensagem está vazia após a formatação")
+ }
+ if (plainLength > 4000) {
+ throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
+ }
+
+ const authorSnapshot: CommentAuthorSnapshot = {
+ name: participant.user.name,
+ email: participant.user.email,
+ avatarUrl: participant.user.avatarUrl ?? undefined,
+ teams: participant.user.teams ?? undefined,
+ }
+
+ const messageId = await ctx.db.insert("ticketChatMessages", {
+ ticketId,
+ tenantId: ticketDoc.tenantId,
+ companyId: ticketDoc.companyId ?? undefined,
+ authorId: actorId,
+ authorSnapshot,
+ body: normalizedBody,
+ attachments: files,
+ notifiedAt: undefined,
+ createdAt: now,
+ updatedAt: now,
+ readBy: [{ userId: actorId, readAt: now }],
+ })
+
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "CHAT_MESSAGE_ADDED",
+ payload: {
+ messageId,
+ authorId: actorId,
+ authorName: participant.user.name,
+ actorRole: participant.role ?? null,
+ },
+ createdAt: now,
+ })
+
+ await ctx.db.patch(ticketId, { updatedAt: now })
+
+ return { ok: true, messageId }
+ },
+})
+
+export const markChatRead = mutation({
+ args: {
+ ticketId: v.id("tickets"),
+ actorId: v.id("users"),
+ messageIds: v.array(v.id("ticketChatMessages")),
+ },
+ handler: async (ctx, { ticketId, actorId, messageIds }) => {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+ const ticketDoc = ticket as Doc<"tickets">
+ await requireTicketChatParticipant(ctx, actorId, ticketDoc)
+ const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id))))
+ const now = Date.now()
+ for (const id of uniqueIds) {
+ const message = await ctx.db.get(id as Id<"ticketChatMessages">)
+ if (!message || String(message.ticketId) !== String(ticketId)) {
+ continue
+ }
+ const readBy = new Map; readAt: number }>()
+ for (const entry of message.readBy ?? []) {
+ readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt })
+ }
+ readBy.set(String(actorId), { userId: actorId, readAt: now })
+ await ctx.db.patch(id as Id<"ticketChatMessages">, {
+ readBy: Array.from(readBy.values()),
+ updatedAt: now,
+ })
+ }
+ return { ok: true }
+ },
+})
+
+export async function submitCsatHandler(
+ ctx: MutationCtx,
+ { ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }
+) {
+ const ticket = await ctx.db.get(ticketId)
+ if (!ticket) {
+ throw new ConvexError("Ticket não encontrado")
+ }
+
+ const normalizedStatus = normalizeStatus(ticket.status)
+ if (normalizedStatus !== "RESOLVED") {
+ throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado")
+ }
+
+ const viewer = await requireUser(ctx, actorId, ticket.tenantId)
+ const normalizedRole = (viewer.role ?? "").toUpperCase()
+ if (normalizedRole !== "COLLABORATOR") {
+ throw new ConvexError("Somente o solicitante pode avaliar o chamado")
+ }
+
+ const viewerEmail = viewer.user.email.trim().toLowerCase()
+ const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null
+ const isOwnerById = String(ticket.requesterId) === String(viewer.user._id)
+ const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false
+ if (!isOwnerById && !isOwnerByEmail) {
+ throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado")
+ }
+
+ if (typeof ticket.csatScore === "number") {
+ throw new ConvexError("Este chamado já possui uma avaliação registrada")
+ }
+
+ if (!Number.isFinite(score)) {
+ throw new ConvexError("Pontuação inválida")
+ }
+ const resolvedMaxScore =
+ Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5
+ const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score)))
+ const normalizedComment =
+ typeof comment === "string"
+ ? comment
+ .replace(/\r\n/g, "\n")
+ .split("\n")
+ .map((line) => line.trim())
+ .join("\n")
+ .trim()
+ : ""
+ if (normalizedComment.length > 2000) {
+ throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)")
+ }
+
+ const now = Date.now()
+
+ let csatAssigneeId: Id<"users"> | undefined
+ let csatAssigneeSnapshot:
+ | {
+ name: string
+ email?: string
+ avatarUrl?: string
+ teams?: string[]
+ }
+ | undefined
+
+ if (ticket.assigneeId) {
+ const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null
+ if (assigneeDoc) {
+ csatAssigneeId = assigneeDoc._id
+ csatAssigneeSnapshot = {
+ name: assigneeDoc.name,
+ email: assigneeDoc.email,
+ avatarUrl: assigneeDoc.avatarUrl ?? undefined,
+ teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined,
+ }
+ } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
+ const snapshot = ticket.assigneeSnapshot as {
+ name?: string
+ email?: string
+ avatarUrl?: string
+ teams?: string[]
+ }
+ if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
+ csatAssigneeId = ticket.assigneeId
+ csatAssigneeSnapshot = {
+ name: snapshot.name,
+ email: snapshot.email ?? undefined,
+ avatarUrl: snapshot.avatarUrl ?? undefined,
+ teams: snapshot.teams ?? undefined,
+ }
+ }
+ }
+ } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
+ const snapshot = ticket.assigneeSnapshot as {
+ name?: string
+ email?: string
+ avatarUrl?: string
+ teams?: string[]
+ }
+ if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
+ csatAssigneeSnapshot = {
+ name: snapshot.name,
+ email: snapshot.email ?? undefined,
+ avatarUrl: snapshot.avatarUrl ?? undefined,
+ teams: snapshot.teams ?? undefined,
+ }
+ }
+ }
+
+ await ctx.db.patch(ticketId, {
+ csatScore: normalizedScore,
+ csatMaxScore: resolvedMaxScore,
+ csatComment: normalizedComment.length > 0 ? normalizedComment : undefined,
+ csatRatedAt: now,
+ csatRatedBy: actorId,
+ csatAssigneeId,
+ csatAssigneeSnapshot,
+ })
+
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "CSAT_RATED",
+ payload: {
+ score: normalizedScore,
+ maxScore: resolvedMaxScore,
+ comment: normalizedComment.length > 0 ? normalizedComment : undefined,
+ ratedBy: actorId,
+ assigneeId: csatAssigneeId ?? null,
+ assigneeName: csatAssigneeSnapshot?.name ?? null,
+ },
+ createdAt: now,
+ })
+
+ return {
+ ok: true,
+ score: normalizedScore,
+ maxScore: resolvedMaxScore,
+ comment: normalizedComment.length > 0 ? normalizedComment : null,
+ ratedAt: now,
+ }
+}
+
+export const submitCsat = mutation({
+ args: {
+ ticketId: v.id("tickets"),
+ actorId: v.id("users"),
+ score: v.number(),
+ maxScore: v.optional(v.number()),
+ comment: v.optional(v.string()),
+ },
+ handler: submitCsatHandler,
+})
+
export const changeRequester = mutation({
args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, requesterId, actorId }) => {
diff --git a/docs/OPERACAO-PRODUCAO.md b/docs/OPERACAO-PRODUCAO.md
index 699fe55..bdde9e1 100644
--- a/docs/OPERACAO-PRODUCAO.md
+++ b/docs/OPERACAO-PRODUCAO.md
@@ -13,11 +13,11 @@ Nota: este documento foi substituído por `docs/operations.md` e permanece aqui
- Seeds prontos (Better Auth e dados demo Convex).
- Seeds Better Auth automáticos: o container do web executa `pnpm auth:seed` após `prisma migrate deploy`, garantindo usuários padrão em toda inicialização (sem resetar senha existente por padrão).
-### Sessão de máquina (Desktop/Tauri)
+### Sessão de dispositivo (Desktop/Tauri)
- A rota `GET /machines/handshake?token=...&redirect=/portal|/dashboard` é pública no middleware para permitir a criação da sessão "machine" a partir do agente desktop, mesmo sem login prévio.
- Após o handshake, o servidor grava cookies de sessão (Better Auth) e um cookie `machine_ctx` com o vínculo (persona, assignedUser*, etc.). Em seguida, o App carrega `/api/machines/session` para preencher o contexto no cliente (`machineContext`).
- Com esse contexto, o Portal exibe corretamente o nome/e‑mail do colaborador/gestor no cabeçalho e permite abrir chamados em nome do usuário vinculado.
-- Em sessões de máquina, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
+- Em sessões de dispositivo, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
#### Detalhes importantes (aprendidos em produção)
- CORS com credenciais: as rotas `POST /api/machines/sessions` e `GET /machines/handshake` precisam enviar `Access-Control-Allow-Credentials: true` para que os cookies do Better Auth sejam aceitos na WebView.
@@ -72,7 +72,7 @@ SMTP_ENABLE_STARTTLS_AUTO=false
SMTP_TLS=true
MAILER_SENDER_EMAIL="Nome "
-# Máquina/inventário
+# Dispositivo/inventário
MACHINE_PROVISIONING_SECRET=
MACHINE_TOKEN_TTL_MS=2592000000
FLEET_SYNC_SECRET=
@@ -239,7 +239,7 @@ docker run --rm -it \
### Smoke test pós‑deploy (CI)
O pipeline executa um teste rápido após o deploy do Web:
-- Registra uma máquina fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
+- Registra uma dispositivo fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
- Espera `HTTP 201` e extrai `machineToken`
- Envia `heartbeat` e espera `HTTP 200`
- Se falhar, o job é marcado como erro (evita regressões silenciosas)
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
index b278960..300a7b5 100644
--- a/docs/OPERATIONS.md
+++ b/docs/OPERATIONS.md
@@ -59,28 +59,28 @@ Este documento consolida as mudanças recentes, o racional por trás delas e o p
- “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`).
- “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`.
-## 4) Troca de colaborador / reaproveitamento de máquina
+## 4) Troca de colaborador / reaproveitamento de dispositivo
-Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma máquina nova), siga este checklist para manter o inventário consistente:
+Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma dispositivo nova), siga este checklist para manter o inventário consistente:
-1. **No painel (Admin → Máquinas)**
- - Abra os detalhes da máquina que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
+1. **No painel (Admin → Dispositivos)**
+ - Abra os detalhes da dispositivo que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
- Clique em **Resetar agente**. Isso revoga todos os tokens gerados para aquele equipamento; ele precisará ser reprovisionado antes de voltar a reportar dados.
- Abra **Ajustar acesso** e altere o e-mail para o do novo usuário (Maria). Assim, quando o agente se registrar novamente, o painel já mostrará a responsável correta.
-2. **Na máquina física que ficará com o novo colaborador**
+2. **Na dispositivo física que ficará com o novo colaborador**
- Desinstale o desktop agent (Painel de Controle → remover programas).
- - Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da máquina, mantendo o histórico.
+ - Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da dispositivo, mantendo o histórico.
-3. **Máquina nova para o colaborador antigo**
- - Instale o desktop agent do zero na máquina que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
- - A máquina azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
+3. **Dispositivo nova para o colaborador antigo**
+ - Instale o desktop agent do zero na dispositivo que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
+ - A dispositivo azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
4. **Verificação final**
- - A máquina antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
- - A máquina nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
+ - A dispositivo antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
+ - A dispositivo nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
-> Não é necessário excluir registros. Cada máquina mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
+> Não é necessário excluir registros. Cada dispositivo mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
- Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
- Como forçar o deploy do Convex
@@ -161,7 +161,7 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
- Front envia `assigneeId` e o backend Convex deve aceitar esse parâmetro (função `tickets.list`).
- Se necessário, forçar redeploy das funções (`convex/**`).
-- Admin ▸ Máquinas travado em skeleton infinito
+- Admin ▸ Dispositivos travado em skeleton infinito
- Abra o DevTools (console) e filtre por `admin-machine-details`. Se o log mostrar `machineId: undefined`, o componente não recebeu o parâmetro da rota.
- Verifique se o `page.tsx` está passando `params.id` corretamente ou se o componente client-side usa `useParams()` / `machineId` opcional.
- Deploys antigos antes de `fix(machines): derive machine id from router params` precisam desse patch; sem ele o fallback nunca dispara e o skeleton permanece.
@@ -170,30 +170,30 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
Última atualização: sincronizado após o deploy bem‑sucedido do Convex e do Front (20/10/2025).
-## 9) Admin ▸ Usuários e Máquinas — Unificação e UX
+## 9) Admin ▸ Usuários e Dispositivos — Unificação e UX
-Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de máquina” e melhorar o filtro em Máquinas:
+Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de dispositivo” e melhorar o filtro em Dispositivos:
-- Unificação de “Usuários” e “Agentes de máquina”
- - Antes: abas separadas “Usuários” (pessoas) e “Agentes de máquina”.
- - Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Máquinas).
+- Unificação de “Usuários” e “Agentes de dispositivo”
+ - Antes: abas separadas “Usuários” (pessoas) e “Agentes de dispositivo”.
+ - Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Dispositivos).
- Onde: `src/components/admin/admin-users-manager.tsx:923`, aba `value="users"` em `:1147`.
- Motivo: evitar confusão entre “usuário” e “agente”; agentes são um tipo especial de usuário (role=machine). A unificação torna “Convites e Acessos” mais direta.
-- Máquinas ▸ Filtro por Empresa com busca e remoção do filtro de SO
+- Dispositivos ▸ Filtro por Empresa com busca e remoção do filtro de SO
- Adicionado dropdown de “Empresa” com busca (Popover + Input) e removido o filtro por Sistema Operacional.
- - Onde: `src/components/admin/machines/admin-machines-overview.tsx:1038` e `:1084`.
+ - Onde: `src/components/admin/devices/admin-devices-overview.tsx:1038` e `:1084`.
- Motivo: fluxo real usa empresas com mais frequência; filtro por SO era menos útil agora.
- Windows ▸ Rótulo do sistema sem duplicidade do “major”
- Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”.
- - Onde: `src/components/admin/machines/admin-machines-overview.tsx` (função `formatOsVersionDisplay`).
+ - Onde: `src/components/admin/devices/admin-devices-overview.tsx` (função `formatOsVersionDisplay`).
- Motivo: legibilidade e padronização em chips/cartões.
-- Vínculos visuais entre máquinas e pessoas
- - Cards de máquinas mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/machines/admin-machines-overview.tsx:3198`.
- - Editor de usuário exibe “Máquinas vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Máquinas vinculadas” no sheet de edição).
- - Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Máquinas.
+- Vínculos visuais entre dispositivos e pessoas
+ - Cards de dispositivos mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/devices/admin-devices-overview.tsx:3198`.
+ - Editor de usuário exibe “Dispositivos vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Dispositivos vinculadas” no sheet de edição).
+ - Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Dispositivos.
### Identidade, e‑mail e histórico (reinstalação)
@@ -202,20 +202,20 @@ Resumo das mudanças aplicadas no painel administrativo para simplificar “Usu
- Novo e‑mail como nova conta: se criar um usuário novo (novo `userId`), será considerado um colaborador distinto e não herdará o histórico.
- Caso precise migrar histórico entre contas diferentes (merge), recomendamos endpoint/rotina de “fusão de contas” (remapear `userId` antigo → novo). Não é necessário para a troca de e‑mail da mesma conta.
-### Vínculos múltiplos de usuários por máquina (Fase 2)
+### Vínculos múltiplos de usuários por dispositivo (Fase 2)
- Estrutura (Convex):
- `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal).
- Mutations: `machines.linkUser(machineId, email)`, `machines.unlinkUser(machineId, userId)`.
- - APIs admin: `POST /api/admin/machines/links` (body: `{ machineId, email }`), `DELETE /api/admin/machines/links?machineId=..&userId=..`.
+ - APIs admin: `POST /api/admin/devices/links` (body: `{ machineId, email }`), `DELETE /api/admin/devices/links?machineId=..&userId=..`.
- UI:
- - Detalhes da máquina mostram “Usuários vinculados” com remoção por item e campo para adicionar por e‑mail.
- - Editor de usuário mostra “Máquinas vinculadas” consolidando assignment, metadata e `linkedUserIds`.
-- Racional: permitir que uma máquina tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
+ - Detalhes da dispositivo mostram “Usuários vinculados” com remoção por item e campo para adicionar por e‑mail.
+ - Editor de usuário mostra “Dispositivos vinculadas” consolidando assignment, metadata e `linkedUserIds`.
+- Racional: permitir que uma dispositivo tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
### Onde editar
- Usuários (pessoas): editar nome, e‑mail, papel, tenant e empresa; redefinir senha pelo painel. Arquivo: `src/components/admin/admin-users-manager.tsx`.
-- Agentes (máquinas): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Máquinas. Arquivo: `src/components/admin/machines/admin-machines-overview.tsx`.
+- Agentes (dispositivos): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Dispositivos. Arquivo: `src/components/admin/devices/admin-devices-overview.tsx`.
-> Observação operacional: mantivemos o provisionamento de máquinas inalterado (token/e‑mail técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.
+> Observação operacional: mantivemos o provisionamento de dispositivos inalterado (token/e‑mail técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.
diff --git a/docs/admin/admin-inventory-ui.md b/docs/admin/admin-inventory-ui.md
index 5f6757e..9a4c6d9 100644
--- a/docs/admin/admin-inventory-ui.md
+++ b/docs/admin/admin-inventory-ui.md
@@ -1,6 +1,6 @@
-# Admin UI — Inventário por máquina
+# Admin UI — Inventário por dispositivo
-A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
+A página Admin > Dispositivos agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
## Filtros e busca
- Busca livre por hostname, e-mail, MAC e número de série.
@@ -19,15 +19,15 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
- Windows: informações de SO (edição, versão, build, data de instalação, experiência, ativação), resumo de hardware (CPU/Memória/GPU/Discos físicos com suporte a payloads únicos), serviços, lista completa de softwares com ação “Ver todos”, Defender.
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
-- Zona perigosa: ação para excluir a máquina (revoga tokens e remove inventário).
-- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a máquina.
+- Zona perigosa: ação para excluir a dispositivo (revoga tokens e remove inventário).
+- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a dispositivo.
## Exportação
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis).
-- Exportar planilha XLSX completa (`/admin/machines/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
+- Exportar planilha XLSX completa (`/admin/devices/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
- **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas.
- **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.).
- - **Vínculos**: usuários associados à máquina.
+ - **Vínculos**: usuários associados à dispositivo.
- **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba.
- **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes).
- **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive.
@@ -36,7 +36,7 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
- **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado.
- - **Labels**: tags aplicadas à máquina.
+ - **Labels**: tags aplicadas à dispositivo.
- **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
## Notas
diff --git a/docs/admin/companies-expanded-profile.md b/docs/admin/companies-expanded-profile.md
index 0175267..306e6d7 100644
--- a/docs/admin/companies-expanded-profile.md
+++ b/docs/admin/companies-expanded-profile.md
@@ -24,7 +24,7 @@ Este documento resume a ampliação do cadastro de empresas (dados fiscais, cont
- **Nova UI de Empresas** (`AdminCompaniesManager`): substituir pelo layout com listagem filtrável (lista + quadro) e formulário seccional ligado a `companyFormSchema` / `sanitizeCompanyInput`.
- **Form dinâmico**: montar o formulário com React Hook Form + zod resolver usando os schemas/validações já prontos no backend.
- **Área de Clientes → Usuários**: renomear a seção, carregar os novos campos (contatos, localizações, contratos) e reaproveitar as transformações do serviço.
-- **Máquinas**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
+- **Dispositivos**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
- **Qualidade**: ajustar lint/testes após a nova UI e cobrir o fluxo de criação/edição com testes de integração.
> Até que a nova interface seja publicada, a API já aceita todos os campos e qualquer cliente (front, automação, seed) deve usar `company-service.ts` para converter dados de/para Prisma, evitando divergências.
diff --git a/docs/alteracoes-2025-11-03.md b/docs/alteracoes-2025-11-03.md
new file mode 100644
index 0000000..801910d
--- /dev/null
+++ b/docs/alteracoes-2025-11-03.md
@@ -0,0 +1,27 @@
+# Alterações — 03/11/2025
+
+## Concluído
+- [x] Estruturado backend para `Dispositivos`: novos campos no Convex (`deviceType`, `deviceProfile`, custom fields), mutations (`saveDeviceProfile`, `saveDeviceCustomFields`) e tabelas auxiliares (`deviceFields`, `deviceExportTemplates`).
+- [x] Refatorado gerador de inventário XLSX para suportar seleção dinâmica de colunas, campos personalizados e nomenclatura de dispositivos.
+- [x] Renomeado "Máquinas" → "Dispositivos" em toda a navegação, rotas, botões (incluindo destaque superior) e mensagens de erro.
+- [x] UI do painel ajustada com criação manual de dispositivos, gerenciamento de campos personalizados, templates de exportação e inclusão de dispositivos móveis.
+- [x] Fluxo de CSAT revisado: mutation dedicada, timeline enriquecida, formulário de estrelas apenas para solicitante e dashboards com novos filtros/combobox.
+- [x] Diálogo de encerramento de ticket com vínculo opcional a outro ticket, prazo configurável de reabertura (7 ou 14 dias) e mensagem pré-visualizada.
+- [x] Botão de reabrir disponível para solicitante/equipe até o fim do prazo; timeline registra `TICKET_REOPENED`.
+- [x] Chat em tempo real incorporado ao detalhe do ticket (listagem live, envio, leitura automática, bloqueio pós-prazo).
+- [x] Formulários dinâmicos para admissão/desligamento com escopo e permissões por empresa/usuário; `create` envia `formTemplate` e `customFields`.
+- [x] Corrigidos mocks/tipagens das rotinas de resolução e reabertura (`resolveTicketHandler`, `reopenTicketHandler`) garantindo `pnpm lint`, `pnpm test` e `pnpm build` verdes.
+- [x] Atualizado schema/tipagens (`TicketWithDetails`, `ChartTooltipContent`) e dashboards CSAT para suportar reabertura com prazos e tooltips formatados.
+- [x] Reatribuição de chamado sem motivo obrigatório; comentário interno só é criado quando o motivo é preenchido.
+- [x] Botão “Novo dispositivo” reutiliza o mesmo primário padrão do shadcn usado em “Nova empresa”, mantendo a identidade visual.
+- [x] Cartão de CSAT respeita a role normalizada (inclusive em sessões de dispositivos), só aparece para a equipe após o início do atendimento e mostra aviso quando ainda não há avaliações.
+- [x] Dashboard de abertos x resolvidos usa buscas indexadas por data, evitando timeouts no Convex.
+
+## Riscos
+- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção.
+- Testes de SMTP/entregabilidade precisam ser executados para garantir que notificações sigam as novas regras de pausa/comentário.
+
+## Pendências
+- [ ] Validar comportamento de notificações (pausa/comentário) com infraestrutura de e-mail real.
+- [ ] Executar migração de dados existente antes do deploy (mapear máquinas → dispositivos e revisar templates legados).
+- [ ] Cobertura de testes automatizados para chat e formulários dinâmicos (resolve/reopen já cobertos).
diff --git a/docs/archive/plano-app-desktop-maquinas.md b/docs/archive/plano-app-desktop-dispositivos.md
similarity index 83%
rename from docs/archive/plano-app-desktop-maquinas.md
rename to docs/archive/plano-app-desktop-dispositivos.md
index fb3a520..0bf2aed 100644
--- a/docs/archive/plano-app-desktop-maquinas.md
+++ b/docs/archive/plano-app-desktop-dispositivos.md
@@ -1,11 +1,11 @@
-# Plano Integrado – App Desktop & Inventário por Máquina (Arquivo)
+# Plano Integrado – App Desktop & Inventário por Dispositivo (Arquivo)
> Documento vivo. Atualize após cada marco relevante.
## Contexto
- **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar:
- Cliente desktop nativo (Tauri) mantendo UI web e realtime.
- - Autenticação máquina-a-máquina usando tokens derivados do inventário.
+ - Autenticação dispositivo-a-dispositivo usando tokens derivados do inventário.
- Integração com agente de inventário (osquery/FleetDM) para registrar hardware, software e heartbeats.
- Pipeline de distribuição para Windows/macOS/Linux.
- **Escopo inicial:** Focar no fluxo mínimo viável com inventário básico (hostname, OS, identificadores, carga resumida). Métricas avançadas e distribuição automatizada ficam para iteração seguinte.
@@ -21,12 +21,12 @@
| --- | --- | --- |
| Documento de arquitetura e roadmap | 🔄 Em andamento | Estrutura criada, aguardando detalhamento incremental a cada etapa. |
| Projeto Tauri inicial apontando para UI Next | 🔄 Em andamento | Estrutura `apps/desktop` criada; pendente testar build após instalar toolchain Rust. |
-| Schema Convex + tokens de máquina | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
+| Schema Convex + tokens de dispositivo | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
| API de registro/heartbeat e exchange Better Auth | 🔄 Em andamento | Endpoints `/api/machines/*` disponíveis; falta testar fluxo end-to-end com app desktop. |
| Endpoint upsert de inventário dedicado | ✅ Concluído | `POST /api/machines/inventory` (modo por token ou provisioningSecret). |
| Integração FleetDM → Convex (inventário básico) | 🔄 Em andamento | Endpoint `/api/integrations/fleet/hosts` criado; falta validar payload real e ajustes de métricas/empresa. |
-| Admin > Máquinas (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/machines` exibe parque completo com status ao vivo, inventário e métricas. |
-| Ajustes na UI/Next para sessão por máquina | ⏳ A fazer | Detectar token e exibir info da máquina em tickets. |
+| Admin > Dispositivos (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/devices` exibe parque completo com status ao vivo, inventário e métricas. |
+| Ajustes na UI/Next para sessão por dispositivo | ⏳ A fazer | Detectar token e exibir info da dispositivo em tickets. |
| Pipeline de build/distribuição Tauri | ⏳ A fazer | Definir estratégia CI/CD + auto-update. |
| Guia operacional (instalação, uso, suporte) | ⏳ A fazer | Gerar instruções finais com casos de uso. |
@@ -48,23 +48,23 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
## Notas de Implementação (Atual)
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
-- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a máquina e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
+- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a dispositivo e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos.
- URLs configuráveis via `.env` do app desktop:
- `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`).
- `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`.
-- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a máquina no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação top‑level pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes).
+- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a dispositivo no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação top‑level pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes).
- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `pnpm run dev/build`, servir `dist/` e abrir janela 1100x720.
- Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
- Novos endpoints Next:
- - `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`).
+ - `POST /api/machines/register` — provisiona dispositivo, gera token e usuário Better Auth (role `machine`).
- `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL.
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies.
- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`).
-- Rota `GET /machines/handshake` realiza o login automático da máquina (seta cookies e redireciona).
+- Rota `GET /machines/handshake` realiza o login automático da dispositivo (seta cookies e redireciona).
- As rotas `sessions/handshake` foram ajustadas para usar `NextResponse.cookies.set(...)`, aplicando cada cookie da Better Auth (sessão e assinatura) individualmente.
- CORS: as respostas incluem `Access-Control-Allow-Credentials: true` para origens permitidas (Tauri WebView e app).
-- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como máquina.
+- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como dispositivo.
- O desktop pode redirecionar automaticamente para essa página durante os testes.
- Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`.
- Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual.
@@ -73,7 +73,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
- O agente coleta `extended.windows.osInfo` via PowerShell/WMI. Caso o script falhe (política ou permissão), aplicamos um fallback com `sysinfo` para preencher ao menos `ProductName`, `Version` e `BuildNumber` — evitando bloco vazio no inventário exibido.
### Portal do Cliente (UX)
-- Quando autenticado como máquina (colaborador/gestor):
+- Quando autenticado como dispositivo (colaborador/gestor):
- O portal deriva o papel a partir do `machineContext` (mesmo se `/api/auth/get-session` vier `null`).
- A listagem exibe apenas os tickets onde o colaborador é o solicitante.
- Informações internas (Fila, Prioridade) são ocultadas; o responsável aparece quando definido.
@@ -81,7 +81,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
- O botão “Sair” é ocultado no desktop (faz sentido apenas fechar o app).
- Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias).
- Variável adicional `FLEET_SYNC_SECRET` (opcional) para autenticar webhook do Fleet; se ausente, reutiliza `MACHINE_PROVISIONING_SECRET`.
-- Dashboard administrativo: `/admin/machines` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
+- Dashboard administrativo: `/admin/devices` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
### Checklist de dependências Tauri (Linux)
```bash
diff --git a/docs/archive/status-2025-10-16.md b/docs/archive/status-2025-10-16.md
index 69488df..b0f62a3 100644
--- a/docs/archive/status-2025-10-16.md
+++ b/docs/archive/status-2025-10-16.md
@@ -13,8 +13,8 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
| Item | Descrição | Impacto |
| --- | --- | --- |
-| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da máquina (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
-| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para máquinas/colaboradores. |
+| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da dispositivo (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
+| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para dispositivos/colaboradores. |
| **Ticket header** | Sincronização do responsável com dependências completas (`ticket-summary-header.tsx`). | Removeu warning do lint e previne estados inconsistentes. |
| **Upgrade para Next.js 16 beta** | Dependências atualizadas (`next@16.0.0-beta.0`, `eslint-config-next@16.0.0-beta.0`), cache de filesystem do Turbopack habilitado, scripts de lint/test/build ajustados ao novo fluxo. | Projeto pronto para validar as novidades do Next 16 (React Compiler opcional, prefetch incremental, etc.); builds e testes já rodando com sucesso. |
| **Posture / inventário** | Type guards e normalização de métricas SMART/serviços (`convex/machines.ts`). | Reduziu `any`, melhorou detecção de alertas e consistência do metadata. |
@@ -39,7 +39,7 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
| Cenário | Sintoma | Como resolver |
| --- | --- | --- |
-| Token de máquina revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
+| Token de dispositivo revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
| Falha de heartbeat | Logs com `Falha ao registrar heartbeat` + status 500 | Verificar `NEXT_PUBLIC_CONVEX_URL` e conectividade. Roda `pnpm convext:dev` em DEV para confirmar schema. |
| Updater sem atualização | Desktop fica em “Procurando atualização” indefinidamente | Confirmar release publicado com `latest.json` apontando para URLs públicas do bundle e assinaturas válidas. |
@@ -47,6 +47,6 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
- Monitorar execução do novo workflow de qualidade em PRs.
- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update).
-- Revisar backlog acima e priorizar smoke tests para o fluxo da máquina.
+- Revisar backlog acima e priorizar smoke tests para o fluxo da dispositivo.
_Última atualização: 16/10/2025 (UTC-3)._
diff --git a/docs/desktop/handshake-troubleshooting.md b/docs/desktop/handshake-troubleshooting.md
index e918bdf..86e6b72 100644
--- a/docs/desktop/handshake-troubleshooting.md
+++ b/docs/desktop/handshake-troubleshooting.md
@@ -1,9 +1,9 @@
-# Desktop (Tauri) — Handshake, Sessão de Máquina e Antivírus
+# Desktop (Tauri) — Handshake, Sessão de Dispositivo e Antivírus
Este documento consolida as orientações e diagnósticos sobre o fluxo do agente desktop, handshake na web e possíveis interferências de antivírus.
## Sintomas observados
-- Ao clicar em “Registrar máquina”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
+- Ao clicar em “Registrar dispositivo”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
- Após o registro, ao abrir a UI web: cabeçalho mostra “Cliente / Sem e‑mail definido” e o Portal não permite abrir chamados.
- No passado, mesmo quando o app “entrava direto”, o Portal não refletia o colaborador/gestor vinculado (sem assignedUser); receio de repetir o problema.
@@ -19,11 +19,11 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
## O que já foi aplicado no projeto
- Middleware permite `GET /machines/handshake` sem exigir login (rota pública).
- Frontend preenche `machineContext` chamando `GET /api/machines/session` (assignedUserId/email/nome/persona) e usa esse ID ao abrir chamados.
-- UI oculta “Sair” quando a sessão é de máquina (portal e shell interno).
+- UI oculta “Sair” quando a sessão é de dispositivo (portal e shell interno).
- DevTools habilitado no desktop (F12, Ctrl+Shift+I ou botão direito com Ctrl/Shift).
- Desktop salva dados em `C:\\Raven\\data\\machine-agent.json` (ou equivalente ao lado do executável), com fallback para AppData se a pasta do app não permitir escrita.
-## Validação rápida (após “Registrar máquina”)
+## Validação rápida (após “Registrar dispositivo”)
1) No executável, com DevTools:
```js
fetch('/api/machines/session').then(r => r.status).then(console.log)
@@ -33,7 +33,7 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
```
2) Na UI (Portal/Topo):
- Mostrar nome/e‑mail do colaborador/gestor (não “Cliente / Sem e‑mail definido”).
- - Sem botão “Sair” (sessão de máquina).
+ - Sem botão “Sair” (sessão de dispositivo).
3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`).
Se `GET /api/machines/session` retornar 403:
@@ -68,4 +68,4 @@ Se `GET /api/machines/session` retornar 403:
---
-Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de máquina, dados persistidos junto ao executável e DevTools habilitado.
+Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de dispositivo, dados persistidos junto ao executável e DevTools habilitado.
diff --git a/docs/desktop/updater.md b/docs/desktop/updater.md
index ebb0c14..e659828 100644
--- a/docs/desktop/updater.md
+++ b/docs/desktop/updater.md
@@ -12,7 +12,7 @@ Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri
```
- Privada: `~/.tauri/raven.key` (nunca compartilhar)
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`)
- - Se for buildar em outra máquina (ex.: Windows), copie os dois arquivos para `C:\Users\\.tauri\raven.key(.pub)`.
+ - Se for buildar em outra dispositivo (ex.: Windows), copie os dois arquivos para `C:\Users\\.tauri\raven.key(.pub)`.
2. **Verificar o `tauri.conf.json`**
```json
diff --git a/docs/historico-agente-desktop-2025-10-10.md b/docs/historico-agente-desktop-2025-10-10.md
index 012f910..caae21f 100644
--- a/docs/historico-agente-desktop-2025-10-10.md
+++ b/docs/historico-agente-desktop-2025-10-10.md
@@ -61,8 +61,8 @@ Referências úteis:
- Abrir no navegador padrão com `openUrl` (`apps/desktop/src/main.ts:694`).
- Se necessário, limpar Store via botão "Reprovisionar" (Configurações) ou removendo o arquivo `machine-agent.json` no diretório de dados do app.
- Mensagem de erro genérica no desktop:
- - Antes: "Erro desconhecido ao registrar a máquina".
- - Agora: exibe `Falha ao registrar máquina (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
+ - Antes: "Erro desconhecido ao registrar a dispositivo".
+ - Agora: exibe `Falha ao registrar dispositivo (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
## Provisionamento — segredo e boas práticas
- Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend).
@@ -74,7 +74,7 @@ Referências úteis:
docker service update --force sistema_convex_backend
```
3. Validar com `POST /api/machines/register` (esperado 201).
-- Máquinas já registradas não são afetadas (token delas continua válido).
+- Dispositivos já registradas não são afetadas (token delas continua válido).
## Pendências e próximos passos
- Mapear erros "esperados" para HTTP adequado no web (Next):
@@ -90,9 +90,9 @@ Referências úteis:
## Checklist rápido de verificação (QA)
- `.env` do desktop contém apenas `VITE_APP_URL` e `VITE_API_BASE_URL` apontando para produção.
-- Primeiro registro sem empresa retorna 201 e aparece "Máquina provisionada" nas abas.
+- Primeiro registro sem empresa retorna 201 e aparece "Dispositivo provisionada" nas abas.
- "Ambiente" e "API" em Configurações exibem `https://tickets.esdrasrenan.com.br`.
-- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a máquina.
+- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a dispositivo.
- Reprovisionar limpa a Store e volta ao formulário inicial.
---
diff --git a/src/app/admin/devices/[id]/page.tsx b/src/app/admin/devices/[id]/page.tsx
new file mode 100644
index 0000000..61511ba
--- /dev/null
+++ b/src/app/admin/devices/[id]/page.tsx
@@ -0,0 +1,20 @@
+import { AppShell } from "@/components/app-shell"
+import { SiteHeader } from "@/components/site-header"
+import { DEFAULT_TENANT_ID } from "@/lib/constants"
+import { AdminDeviceDetailsClient } from "@/components/admin/devices/admin-device-details.client"
+import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client"
+
+export const runtime = "nodejs"
+export const dynamic = "force-dynamic"
+
+export default async function AdminDeviceDetailsPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ return (
+ }>
+