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 ( + }> +
+ + +
+
+ ) +} diff --git a/src/app/admin/devices/[id]/tickets/page.tsx b/src/app/admin/devices/[id]/tickets/page.tsx new file mode 100644 index 0000000..3410d9b --- /dev/null +++ b/src/app/admin/devices/[id]/tickets/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client" +import { DeviceTicketsHistoryClient } from "@/components/admin/devices/device-tickets-history.client" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminDeviceTicketsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + return ( + }> +
+ + +
+
+ ) +} diff --git a/src/app/admin/machines/page.tsx b/src/app/admin/devices/page.tsx similarity index 62% rename from src/app/admin/machines/page.tsx rename to src/app/admin/devices/page.tsx index c95a594..403e6fc 100644 --- a/src/app/admin/machines/page.tsx +++ b/src/app/admin/devices/page.tsx @@ -1,12 +1,12 @@ import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" -import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview" +import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview" import { DEFAULT_TENANT_ID } from "@/lib/constants" export const runtime = "nodejs" export const dynamic = "force-dynamic" -export default async function AdminMachinesPage({ +export default async function AdminDevicesPage({ searchParams, }: { searchParams: Promise> }) { const params = await searchParams @@ -16,13 +16,13 @@ export default async function AdminMachinesPage({ } >
- +
) diff --git a/src/app/admin/machines/[id]/page.tsx b/src/app/admin/machines/[id]/page.tsx deleted file mode 100644 index 5ca5bd5..0000000 --- a/src/app/admin/machines/[id]/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { AppShell } from "@/components/app-shell" -import { SiteHeader } from "@/components/site-header" -import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { AdminMachineDetailsClient } from "@/components/admin/machines/admin-machine-details.client" -import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client" - -export const runtime = "nodejs" -export const dynamic = "force-dynamic" - -export default async function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - return ( - }> -
- - -
-
- ) -} diff --git a/src/app/admin/machines/[id]/tickets/page.tsx b/src/app/admin/machines/[id]/tickets/page.tsx deleted file mode 100644 index 3f2b49f..0000000 --- a/src/app/admin/machines/[id]/tickets/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AppShell } from "@/components/app-shell" -import { SiteHeader } from "@/components/site-header" -import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client" -import { MachineTicketsHistoryClient } from "@/components/admin/machines/machine-tickets-history.client" - -export const runtime = "nodejs" -export const dynamic = "force-dynamic" - -export default async function AdminMachineTicketsPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - - return ( - }> -
- - -
-
- ) -} diff --git a/src/app/api/admin/machines/[id]/details/route.ts b/src/app/api/admin/devices/[id]/details/route.ts similarity index 90% rename from src/app/api/admin/machines/[id]/details/route.ts rename to src/app/api/admin/devices/[id]/details/route.ts index 7e3f9aa..52cfa54 100644 --- a/src/app/api/admin/machines/[id]/details/route.ts +++ b/src/app/api/admin/devices/[id]/details/route.ts @@ -10,7 +10,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string const client = createConvexClient() const { id } = await ctx.params const machineId = id as Id<"machines"> - const data = (await client.query(api.machines.getById, { id: machineId, includeMetadata: true })) as unknown + const data = (await client.query(api.devices.getById, { id: machineId, includeMetadata: true })) as unknown if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 }) return NextResponse.json(data, { status: 200 }) } catch (err) { diff --git a/src/app/api/admin/machines/[id]/inventory.xlsx/route.ts b/src/app/api/admin/devices/[id]/inventory.xlsx/route.ts similarity index 92% rename from src/app/api/admin/machines/[id]/inventory.xlsx/route.ts rename to src/app/api/admin/devices/[id]/inventory.xlsx/route.ts index ccfbf27..f27e369 100644 --- a/src/app/api/admin/machines/[id]/inventory.xlsx/route.ts +++ b/src/app/api/admin/devices/[id]/inventory.xlsx/route.ts @@ -36,13 +36,13 @@ export async function GET(_request: Request, context: RouteContext) { const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID try { - const machine = (await client.query(api.machines.getById, { + const machine = (await client.query(api.devices.getById, { id: machineId, includeMetadata: true, })) as MachineInventoryRecord | null if (!machine || machine.tenantId !== tenantId) { - return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 }) + return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 }) } const workbook = buildMachinesInventoryWorkbook([machine], { @@ -64,6 +64,6 @@ export async function GET(_request: Request, context: RouteContext) { }) } catch (error) { console.error("Failed to export machine inventory", error) - return NextResponse.json({ error: "Falha ao gerar planilha da máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao gerar planilha da dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/machines/access/route.ts b/src/app/api/admin/devices/access/route.ts similarity index 90% rename from src/app/api/admin/machines/access/route.ts rename to src/app/api/admin/devices/access/route.ts index e1baf18..32d100b 100644 --- a/src/app/api/admin/machines/access/route.ts +++ b/src/app/api/admin/devices/access/route.ts @@ -38,7 +38,7 @@ export async function POST(request: Request) { const client = new ConvexHttpClient(convexUrl) try { - const machine = (await client.query(api.machines.getContext, { + const machine = (await client.query(api.devices.getContext, { machineId: parsed.machineId as Id<"machines">, })) as { id: string @@ -47,7 +47,7 @@ export async function POST(request: Request) { } | null if (!machine) { - return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 }) + return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 }) } const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID @@ -61,7 +61,7 @@ export async function POST(request: Request) { companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined, })) as { _id?: Id<"users"> } | null - await client.mutation(api.machines.updatePersona, { + await client.mutation(api.devices.updatePersona, { machineId: parsed.machineId as Id<"machines">, persona: parsed.persona, assignedUserId: ensuredUser?._id, @@ -73,6 +73,6 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }) } catch (error) { console.error("[machines.access]", error) - return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao atualizar acesso da dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/machines/delete/route.test.ts b/src/app/api/admin/devices/delete/route.test.ts similarity index 91% rename from src/app/api/admin/machines/delete/route.test.ts rename to src/app/api/admin/devices/delete/route.test.ts index 3936cd7..5f62a1d 100644 --- a/src/app/api/admin/machines/delete/route.test.ts +++ b/src/app/api/admin/devices/delete/route.test.ts @@ -28,7 +28,7 @@ vi.mock("@/lib/auth-server", () => ({ assertAuthenticatedSession: assertAuthenticatedSession, })) -describe("POST /api/admin/machines/delete", () => { +describe("POST /api/admin/devices/delete", () => { const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL let restoreConsole: (() => void) | undefined @@ -65,7 +65,7 @@ describe("POST /api/admin/machines/delete", () => { it("returns ok when the machine removal succeeds", async () => { const { POST } = await import("./route") const response = await POST( - new Request("http://localhost/api/admin/machines/delete", { + new Request("http://localhost/api/admin/devices/delete", { method: "POST", body: JSON.stringify({ machineId: "jn_machine" }), }) @@ -81,13 +81,13 @@ describe("POST /api/admin/machines/delete", () => { it("still succeeds when the Convex machine is already missing", async () => { mutationMock.mockImplementation(async (_ctx, payload) => { if (payload && typeof payload === "object" && "machineId" in payload) { - throw new Error("Máquina não encontrada") + throw new Error("Dispositivo não encontrada") } return { _id: "user_123" } }) const { POST } = await import("./route") const response = await POST( - new Request("http://localhost/api/admin/machines/delete", { + new Request("http://localhost/api/admin/devices/delete", { method: "POST", body: JSON.stringify({ machineId: "jn_machine" }), }) @@ -107,14 +107,14 @@ describe("POST /api/admin/machines/delete", () => { }) const { POST } = await import("./route") const response = await POST( - new Request("http://localhost/api/admin/machines/delete", { + new Request("http://localhost/api/admin/devices/delete", { method: "POST", body: JSON.stringify({ machineId: "jn_machine" }), }) ) expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ error: "Falha ao remover máquina no Convex" }) + await expect(response.json()).resolves.toEqual({ error: "Falha ao remover dispositivo no Convex" }) expect(deleteManyMock).not.toHaveBeenCalled() }) }) diff --git a/src/app/api/admin/machines/delete/route.ts b/src/app/api/admin/devices/delete/route.ts similarity index 88% rename from src/app/api/admin/machines/delete/route.ts rename to src/app/api/admin/devices/delete/route.ts index 8d07458..f05a30f 100644 --- a/src/app/api/admin/machines/delete/route.ts +++ b/src/app/api/admin/devices/delete/route.ts @@ -50,17 +50,17 @@ export async function POST(request: Request) { let machineMissing = false try { - await convex.mutation(api.machines.remove, { + await convex.mutation(api.devices.remove, { machineId: parsed.data.machineId as Id<"machines">, actorId, }) } catch (error) { const message = error instanceof Error ? error.message : "" - if (message.includes("Máquina não encontrada")) { + if (message.includes("Dispositivo não encontrada")) { machineMissing = true } else { console.error("[machines.delete] Convex failure", error) - return NextResponse.json({ error: "Falha ao remover máquina no Convex" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao remover dispositivo no Convex" }, { status: 500 }) } } @@ -70,6 +70,6 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true, machineMissing }) } catch (error) { console.error("[machines.delete] Falha ao excluir", error) - return NextResponse.json({ error: "Falha ao excluir máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao excluir dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/machines/links/route.ts b/src/app/api/admin/devices/links/route.ts similarity index 95% rename from src/app/api/admin/machines/links/route.ts rename to src/app/api/admin/devices/links/route.ts index 4bbb858..ef892cd 100644 --- a/src/app/api/admin/machines/links/route.ts +++ b/src/app/api/admin/devices/links/route.ts @@ -27,7 +27,7 @@ export async function POST(request: Request) { const client = new ConvexHttpClient(convexUrl) try { - await client.mutation(api.machines.linkUser, { + await client.mutation(api.devices.linkUser, { machineId: parsed.machineId as Id<"machines">, email: parsed.email, }) @@ -53,7 +53,7 @@ export async function DELETE(request: Request) { const client = new ConvexHttpClient(convexUrl) try { - await client.mutation(api.machines.unlinkUser, { + await client.mutation(api.devices.unlinkUser, { machineId: parsed.data.machineId as Id<"machines">, userId: parsed.data.userId as Id<"users">, }) diff --git a/src/app/api/admin/machines/remote-access/route.ts b/src/app/api/admin/devices/remote-access/route.ts similarity index 100% rename from src/app/api/admin/machines/remote-access/route.ts rename to src/app/api/admin/devices/remote-access/route.ts diff --git a/src/app/api/admin/machines/rename/route.ts b/src/app/api/admin/devices/rename/route.ts similarity index 95% rename from src/app/api/admin/machines/rename/route.ts rename to src/app/api/admin/devices/rename/route.ts index 7fea9f1..9fd7250 100644 --- a/src/app/api/admin/machines/rename/route.ts +++ b/src/app/api/admin/devices/rename/route.ts @@ -59,7 +59,7 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }) } catch (error) { console.error("[machines.rename] Falha ao renomear", error) - return NextResponse.json({ error: "Falha ao renomear máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao renomear dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/machines/reset-agent/route.ts b/src/app/api/admin/devices/reset-agent/route.ts similarity index 98% rename from src/app/api/admin/machines/reset-agent/route.ts rename to src/app/api/admin/devices/reset-agent/route.ts index 26916e2..cbe4c28 100644 --- a/src/app/api/admin/machines/reset-agent/route.ts +++ b/src/app/api/admin/devices/reset-agent/route.ts @@ -54,7 +54,7 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 }) } catch (error) { console.error("[machines.resetAgent] Falha ao resetar agente", error) - return NextResponse.json({ error: "Falha ao resetar agente da máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao resetar agente da dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/machines/toggle-active/route.ts b/src/app/api/admin/devices/toggle-active/route.ts similarity index 98% rename from src/app/api/admin/machines/toggle-active/route.ts rename to src/app/api/admin/devices/toggle-active/route.ts index 2347942..b77e6bf 100644 --- a/src/app/api/admin/machines/toggle-active/route.ts +++ b/src/app/api/admin/devices/toggle-active/route.ts @@ -56,6 +56,6 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }) } catch (error) { console.error("[machines.toggleActive] Falha ao atualizar status", error) - return NextResponse.json({ error: "Falha ao atualizar status da máquina" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao atualizar status da dispositivo" }, { status: 500 }) } } diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 1c54255..e5ab37a 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -41,7 +41,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } if (targetRole === "machine") { - return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 }) + return NextResponse.json({ error: "Contas de dispositivo não possuem senha web" }, { status: 400 }) } const body = (await request.json().catch(() => null)) as { password?: string } | null diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index ecb8ff3..1ebceaf 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -158,7 +158,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } if ((user.role ?? "").toLowerCase() === "machine") { - return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 }) + return NextResponse.json({ error: "Ajustes de dispositivos devem ser feitos em Admin ▸ Dispositivos" }, { status: 400 }) } if (!sessionIsAdmin && !canManageRole(nextRole)) { @@ -356,7 +356,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str } if (target.role === "machine") { - return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 }) + return NextResponse.json({ error: "Os agentes de dispositivo devem ser removidos via módulo de dispositivos." }, { status: 400 }) } if (target.email === session.user.email) { diff --git a/src/app/api/integrations/fleet/hosts/route.ts b/src/app/api/integrations/fleet/hosts/route.ts index 0ffc1d2..732cbeb 100644 --- a/src/app/api/integrations/fleet/hosts/route.ts +++ b/src/app/api/integrations/fleet/hosts/route.ts @@ -162,7 +162,7 @@ export async function POST(request: Request) { const client = new ConvexHttpClient(convexUrl) try { - const result = await client.mutation(api.machines.upsertInventory, { + const result = await client.mutation(api.devices.upsertInventory, { provisioningCode: fleetSecret, hostname, os: osInfo, diff --git a/src/app/api/machines/heartbeat/route.test.ts b/src/app/api/machines/heartbeat/route.test.ts index 856c07b..03aa7c3 100644 --- a/src/app/api/machines/heartbeat/route.test.ts +++ b/src/app/api/machines/heartbeat/route.test.ts @@ -36,7 +36,7 @@ describe("POST /api/machines/heartbeat", () => { expect(response.status).toBe(200) const body = await response.json() expect(body).toEqual({ ok: true }) - expect(mutationMock).toHaveBeenCalledWith(api.machines.heartbeat, payload) + expect(mutationMock).toHaveBeenCalledWith(api.devices.heartbeat, payload) }) it("rejects an invalid payload", async () => { diff --git a/src/app/api/machines/heartbeat/route.ts b/src/app/api/machines/heartbeat/route.ts index d4902c9..cb520ee 100644 --- a/src/app/api/machines/heartbeat/route.ts +++ b/src/app/api/machines/heartbeat/route.ts @@ -56,7 +56,7 @@ export async function POST(request: Request) { } try { - const response = await client.mutation(api.machines.heartbeat, payload) + const response = await client.mutation(api.devices.heartbeat, payload) return jsonWithCors(response, 200, origin, CORS_METHODS) } catch (error) { console.error("[machines.heartbeat] Falha ao registrar heartbeat", error) diff --git a/src/app/api/machines/inventory/route.test.ts b/src/app/api/machines/inventory/route.test.ts index 69cfe40..42dc562 100644 --- a/src/app/api/machines/inventory/route.test.ts +++ b/src/app/api/machines/inventory/route.test.ts @@ -35,7 +35,7 @@ describe("POST /api/machines/inventory", () => { expect(response.status).toBe(200) expect(mutationMock).toHaveBeenCalledWith( - api.machines.heartbeat, + api.devices.heartbeat, expect.objectContaining({ machineToken: "token-123", hostname: "machine", @@ -67,7 +67,7 @@ describe("POST /api/machines/inventory", () => { expect(response.status).toBe(200) expect(mutationMock).toHaveBeenCalledWith( - api.machines.upsertInventory, + api.devices.upsertInventory, expect.objectContaining({ provisioningCode: "a".repeat(32), hostname: "machine", diff --git a/src/app/api/machines/inventory/route.ts b/src/app/api/machines/inventory/route.ts index 99044cb..2ae6fb1 100644 --- a/src/app/api/machines/inventory/route.ts +++ b/src/app/api/machines/inventory/route.ts @@ -64,11 +64,11 @@ export async function POST(request: Request) { ) } - // Modo A: com token da máquina (usa heartbeat para juntar inventário) + // Modo A: com token da dispositivo (usa heartbeat para juntar inventário) const tokenParsed = tokenModeSchema.safeParse(raw) if (tokenParsed.success) { try { - const result = await client.mutation(api.machines.heartbeat, { + const result = await client.mutation(api.devices.heartbeat, { machineToken: tokenParsed.data.machineToken, hostname: tokenParsed.data.hostname, os: tokenParsed.data.os, @@ -87,7 +87,7 @@ export async function POST(request: Request) { const provParsed = provisioningModeSchema.safeParse(raw) if (provParsed.success) { try { - const result = await client.mutation(api.machines.upsertInventory, { + const result = await client.mutation(api.devices.upsertInventory, { provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(), hostname: provParsed.data.hostname, os: provParsed.data.os, diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index 097cdf0..a11a10b 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -120,7 +120,7 @@ export async function POST(request: Request) { provisioningCode: companyRecord.provisioningCode, }) - const registration = await client.mutation(api.machines.register, { + const registration = await client.mutation(api.devices.register, { provisioningCode, hostname: payload.hostname, os: payload.os, @@ -138,7 +138,7 @@ export async function POST(request: Request) { persona, }) - await client.mutation(api.machines.linkAuthAccount, { + await client.mutation(api.devices.linkAuthAccount, { machineId: registration.machineId as Id<"machines">, authUserId: account.authUserId, authEmail: account.authEmail, @@ -165,7 +165,7 @@ export async function POST(request: Request) { if (persona) { assignedUserId = ensuredUser?._id - await client.mutation(api.machines.updatePersona, { + await client.mutation(api.devices.updatePersona, { machineId: registration.machineId as Id<"machines">, persona, ...(assignedUserId ? { assignedUserId } : {}), @@ -174,13 +174,13 @@ export async function POST(request: Request) { assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR", }) } else { - await client.mutation(api.machines.updatePersona, { + await client.mutation(api.devices.updatePersona, { machineId: registration.machineId as Id<"machines">, persona: "", }) } } else { - await client.mutation(api.machines.updatePersona, { + await client.mutation(api.devices.updatePersona, { machineId: registration.machineId as Id<"machines">, persona: "", }) @@ -211,7 +211,7 @@ export async function POST(request: Request) { const isCompanyNotFound = msg.includes("empresa não encontrada") const isConvexError = msg.includes("convexerror") const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500 - const payload = { error: "Falha ao provisionar máquina", details } + const payload = { error: "Falha ao provisionar dispositivo", details } return jsonWithCors(payload, status, origin, CORS_METHODS) } } diff --git a/src/app/api/machines/session/route.test.ts b/src/app/api/machines/session/route.test.ts index 91e2ac4..8df4a68 100644 --- a/src/app/api/machines/session/route.test.ts +++ b/src/app/api/machines/session/route.test.ts @@ -43,7 +43,7 @@ describe("GET /api/machines/session", () => { expect(response.status).toBe(403) const payload = await response.json() - expect(payload).toEqual({ error: "Sessão de máquina não encontrada." }) + expect(payload).toEqual({ error: "Sessão de dispositivo não encontrada." }) expect(mockCreateConvexClient).not.toHaveBeenCalled() }) diff --git a/src/app/api/machines/session/route.ts b/src/app/api/machines/session/route.ts index f4cc1f4..e7a2a39 100644 --- a/src/app/api/machines/session/route.ts +++ b/src/app/api/machines/session/route.ts @@ -21,7 +21,7 @@ export const runtime = "nodejs" export async function GET(request: NextRequest) { const session = await assertAuthenticatedSession() if (!session || session.user?.role !== "machine") { - return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 }) + return NextResponse.json({ error: "Sessão de dispositivo não encontrada." }, { status: 403 }) } let client @@ -42,23 +42,23 @@ export async function GET(request: NextRequest) { if (!machineId) { try { - const lookup = (await client.query(api.machines.findByAuthEmail, { + const lookup = (await client.query(api.devices.findByAuthEmail, { authEmail: session.user.email.toLowerCase(), })) as { id: string } | null if (!lookup?.id) { - return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 }) + return NextResponse.json({ error: "Dispositivo não vinculada à sessão atual." }, { status: 404 }) } machineId = lookup.id as Id<"machines"> } catch (error) { - console.error("[machines.session] Falha ao localizar máquina por e-mail", error) - return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 }) + console.error("[machines.session] Falha ao localizar dispositivo por e-mail", error) + return NextResponse.json({ error: "Não foi possível localizar a dispositivo." }, { status: 500 }) } } try { - let context = (await client.query(api.machines.getContext, { + let context = (await client.query(api.devices.getContext, { machineId, })) as { id: string @@ -109,7 +109,7 @@ export async function GET(request: NextRequest) { ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole ensuredPersona = normalizedPersona - await client.mutation(api.machines.updatePersona, { + await client.mutation(api.devices.updatePersona, { machineId: machineId as Id<"machines">, persona: normalizedPersona, assignedUserId: ensuredUser._id as Id<"users">, @@ -118,7 +118,7 @@ export async function GET(request: NextRequest) { assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(), }) - context = (await client.query(api.machines.getContext, { + context = (await client.query(api.devices.getContext, { machineId, })) as typeof context @@ -172,7 +172,7 @@ export async function GET(request: NextRequest) { return response } catch (error) { - console.error("[machines.session] Falha ao obter contexto da máquina", error) - return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 }) + console.error("[machines.session] Falha ao obter contexto da dispositivo", error) + return NextResponse.json({ error: "Falha ao obter contexto da dispositivo." }, { status: 500 }) } } diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index 207b780..a7c19c7 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -127,13 +127,13 @@ export async function POST(request: Request) { } catch (error) { if (error instanceof MachineInactiveError) { return jsonWithCors( - { error: "Máquina desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, + { error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, 423, origin, CORS_METHODS ) } console.error("[machines.sessions] Falha ao criar sessão", error) - return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, origin, CORS_METHODS) + return jsonWithCors({ error: "Falha ao autenticar dispositivo" }, 500, origin, CORS_METHODS) } } diff --git a/src/app/api/reports/machines-inventory.xlsx/route.ts b/src/app/api/reports/machines-inventory.xlsx/route.ts index 41ad574..1feda15 100644 --- a/src/app/api/reports/machines-inventory.xlsx/route.ts +++ b/src/app/api/reports/machines-inventory.xlsx/route.ts @@ -6,6 +6,7 @@ import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export" +import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" export const runtime = "nodejs" @@ -22,6 +23,31 @@ export async function GET(request: Request) { const companyId = searchParams.get("companyId") ?? undefined const machineIdParams = searchParams.getAll("machineId").filter(Boolean) const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null + const columnsParam = searchParams.get("columns") + let columnConfig: DeviceInventoryColumnConfig[] | undefined + if (columnsParam) { + try { + const parsed = JSON.parse(columnsParam) + if (Array.isArray(parsed)) { + columnConfig = parsed + .map((item) => { + if (typeof item === "string") { + return { key: item } + } + if (item && typeof item === "object" && typeof item.key === "string") { + return { + key: item.key, + label: typeof item.label === "string" && item.label.length > 0 ? item.label : undefined, + } + } + return null + }) + .filter((item): item is DeviceInventoryColumnConfig => item !== null) + } + } catch (error) { + console.warn("Invalid columns parameter for machines export", error) + } + } const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID @@ -46,7 +72,7 @@ export async function GET(request: Request) { } try { - const machines = (await client.query(api.machines.listByTenant, { + const machines = (await client.query(api.devices.listByTenant, { tenantId, includeMetadata: true, })) as MachineInventoryRecord[] @@ -77,6 +103,7 @@ export async function GET(request: Request) { generatedBy: session.user.name ?? session.user.email, companyFilterLabel, generatedAt: new Date(), + columns: columnConfig, }) const body = new Uint8Array(workbook) diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts index 31f4feb..0ea4258 100644 --- a/src/app/machines/handshake/route.ts +++ b/src/app/machines/handshake/route.ts @@ -9,7 +9,7 @@ const ERROR_TEMPLATE = ` - Falha na autenticação da máquina + Falha na autenticação da dispositivo