diff --git a/convex/tickets.ts b/convex/tickets.ts index 5c87caa..e4fdadd 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -541,11 +541,22 @@ async function fetchTicketFieldsByScopes( scopes: string[], companyId: Id<"companies"> | null ): Promise { - const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))); - if (uniqueScopes.length === 0) { + const scopeLookup = new Map(); + scopes.forEach((scope) => { + const trimmed = scope?.trim(); + if (!trimmed) { + return; + } + const normalized = trimmed.toLowerCase(); + if (!scopeLookup.has(normalized)) { + scopeLookup.set(normalized, trimmed); + } + }); + + if (!scopeLookup.size) { return new Map(); } - const scopeSet = new Set(uniqueScopes); + const companyIdStr = companyId ? String(companyId) : null; const result: TicketFieldScopeMap = new Map(); const allFields = await ctx.db @@ -553,21 +564,45 @@ async function fetchTicketFieldsByScopes( .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); - for (const field of allFields) { - const scope = field.scope ?? ""; - if (!scopeSet.has(scope)) { - continue; + const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => { + const originalKey = scopeLookup.get(scopeKey); + if (!originalKey) { + return; } + const current = result.get(originalKey); + if (current) { + current.push(field); + } else { + result.set(originalKey, [field]); + } + }; + + const normalizedScopeSet = new Set(scopeLookup.keys()); + + for (const field of allFields) { + const rawScope = field.scope?.trim(); + const normalizedFieldScope = rawScope && rawScope.length > 0 ? rawScope.toLowerCase() : "all"; const fieldCompanyId = field.companyId ? String(field.companyId) : null; if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) { continue; } - const current = result.get(scope); - if (current) { - current.push(field); - } else { - result.set(scope, [field]); + + if (normalizedFieldScope === "all") { + normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field)); + continue; } + + if (normalizedFieldScope === "default") { + if (normalizedScopeSet.has("default")) { + addFieldToScope("default", field); + } + continue; + } + + if (!normalizedScopeSet.has(normalizedFieldScope)) { + continue; + } + addFieldToScope(normalizedFieldScope, field); } return result; } @@ -2915,7 +2950,16 @@ export const listTicketForms = query({ const viewerRole = (viewer.role ?? "").toUpperCase() const templates = await fetchTemplateSummaries(ctx, tenantId) - const scopes = templates.map((template) => template.key) + const defaultTemplate: TemplateSummary = { + key: "default", + label: "Chamado", + description: "Campos adicionais exibidos em chamados gerais ou sem template específico.", + defaultEnabled: true, + } + + const templatesWithDefault = [defaultTemplate, ...templates.filter((template) => template.key !== "default")] + + const scopes = templatesWithDefault.map((template) => template.key) const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId) const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" @@ -2938,7 +2982,7 @@ export const listTicketForms = query({ }> }> - for (const template of templates) { + for (const template of templatesWithDefault) { const templateSettings = settingsByTemplate.get(template.key) ?? [] let enabled = staffOverride ? true diff --git a/docs/migracao-sistema-2025-11-15.md b/docs/migracao-sistema-2025-11-15.md new file mode 100644 index 0000000..6265bf4 --- /dev/null +++ b/docs/migracao-sistema-2025-11-15.md @@ -0,0 +1,134 @@ +# Migração do stack `sistema` para a VPS 154.12.253.40 (15/11/2025) + +Este documento registra o procedimento completo usado para clonar e mover o stack `sistema` (Convex self-hosted + dashboard + web/Next.js) da VPS antiga `217.216.64.168` para a VPS nova `154.12.253.40` (ambas Ubuntu 24.04 com Docker Swarm). Use-o como referência para futuros blue/green ou rollbacks. + +## 1. Componentes inventariados + +| Item | VPS antiga | VPS nova | +| --- | --- | --- | +| Hostname | `vmi2889318` | `vmi2872619` | +| CPU/RAM | 4 vCPU / 8 GB | 8 vCPU / 24 GB | +| Stack | `sistema_convex_backend`, `sistema_convex_dashboard`, `sistema_web` | os mesmos + outros stacks existentes | +| Redes relevantes | `traefik_public`, `digital_network` | idem | +| Volumes do stack | `sistema_convex_data`, `sistema_sistema_db`, `sistema_db` | copiados 1:1 | +| Bind mount | `/apps/sistema` (5 GB) | `/home/renan/apps/sistema` + symlink `/apps/sistema` | + +Arquivos auxiliares recuperados da VPS antiga e copiados para `/root` na nova: + +- `/root/services.json` – inventário completo de serviços, útil para recriar stacks. +- `/root/replay_services_from_json.sh` – script que recria serviços usando `services.json`. + +## 2. Pré-requisitos e boas práticas + +1. **Autenticação**: senha root na VPS antiga (`sshpass`) e chave `codex_ed25519` para a nova (chmod 600). +2. **Congelamento de escrita**: antes do delta final, escalar a 0 os serviços que escrevem (`docker service scale sistema_* =0`). +3. **Snapshots/volumes**: cópia via contêiner Alpine + tar em streaming evita salvar arquivos temporários. +4. **DNS**: prepare um alias (ex.: `vps2.esdrasrenan.com.br`) e reduza o TTL dos CNAMEs *antes* do corte para minimizar caches antigos. +5. **TLS**: copie `acme.json` entre as VPS e recarregue o Traefik para manter os certificados válidos após o corte. + +## 3. Procedimento realizado + +### 3.1. Inventário e auditoria + +``` +# CPU/memória, Swarm, stacks e volumes na VPS antiga +sshpass -p '...' ssh root@217.216.64.168 'lscpu; free -h; docker info; docker stack ls; docker stack services sistema; docker volume ls' + +# Idem na VPS nova (confirmar recursos extras) +ssh -i codex_ed25519 root@154.12.253.40 'lscpu; free -h; docker stack ls; docker service ls' +``` + +### 3.2. Cópia inicial do código e volumes + +``` +# Código Next.js (bind mount) +sshpass -p '...' ssh root@217.216.64.168 'tar czf - -C /apps sistema' | ssh -i codex_ed25519 root@154.12.253.40 'tar xzf - -C /apps' + +# Volumes Docker em streaming (repetir para cada volume) +for vol in sistema_convex_data sistema_sistema_db sistema_db; do + sshpass -p '...' ssh root@217.216.64.168 "docker run --rm -v $vol:/data alpine tar cz -C /data ." | ssh -i codex_ed25519 root@154.12.253.40 "docker volume create $vol >/dev/null; docker run --rm -i -v $vol:/data alpine tar xz -C /data" +done + +# Ajustar caminho esperado pelo serviço web +ssh root@154.12.253.40 'mkdir -p /home/renan/apps && mv /apps/sistema /home/renan/apps/ && ln -sfn /home/renan/apps/sistema /apps/sistema' +``` + +### 3.3. Recriação dos serviços no Swarm da VPS nova + +Recriação manual (mais controle que o script) mantendo labels do Traefik: + +``` +# Convex backend +NET=traefik_public +STACK=sistema +**docker service create** --name ${STACK}_convex_backend --replicas 1 --constraint 'node.role == manager' --network $NET --mount type=volume,src=sistema_convex_data,dst=/convex/data --limit-memory 12g --env CONVEX_CLOUD_ORIGIN=https://convex.esdrasrenan.com.br --env CONVEX_SITE_ORIGIN=https://convex.esdrasrenan.com.br --env FLEET_SYNC_SECRET=... --env MACHINE_PROVISIONING_SECRET=... --env MACHINE_TOKEN_TTL_MS=2592000000 --env RUST_LOG=info --label traefik.http.routers.sistema_convex.rule='Host(`convex.esdrasrenan.com.br`)' ghcr.io/get-convex/convex-backend:latest + +# Dashboard (mantido 0/0 até ser necessário) +docker service create ... --replicas 0 ghcr.io/get-convex/convex-dashboard:latest + +# Web (Next.js/Bun) – notar bind mount + volume SQLite +docker service create --name ${STACK}_web --replicas 1 --constraint 'node.role == manager' --network $NET --mount type=bind,src=/home/renan/apps/sistema,dst=/app --mount type=volume,src=sistema_sistema_db,dst=/app/data --limit-memory 2g --user 1000:1000 --workdir /app --env NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br --env NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br --env CONVEX_INTERNAL_URL=http://sistema_convex_backend:3210 --env BETTER_AUTH_SECRET=... --label traefik.http.routers.sistema_web.rule='Host(`tickets.esdrasrenan.com.br`)' oven/bun:1.3.1 bash -lc 'bash /app/scripts/start-web.sh' +``` + +### 3.4. Certificados TLS + +1. Copiar `acme.json` das duas VPS. +2. Mesclar certificados (para não perder a conta/chain nova) e reenviar para o volume `certificados`. +3. Escalar `traefik_traefik` para 0, gravar o arquivo e retornar a 1. + +``` +sshpass -p '...' ssh root@217.216.64.168 'docker run --rm -v certificados:/data alpine cat /data/acme.json' > acme-old.json +ssh root@154.12.253.40 'docker run --rm -v certificados:/data alpine cat /data/acme.json' > acme-new.json +python3 merge_acme.py # script que mescla Certificates +cat acme-merged.json | ssh root@154.12.253.40 'docker run --rm -i -v certificados:/data alpine sh -c "cat > /data/acme.json && chmod 600 /data/acme.json"' +``` + +### 3.5. Delta final antes do corte + +1. `docker service scale sistema_* =0` na VPS antiga. +2. Repetir o loop de volumes + `tar` do código para pegar qualquer alteração mais recente. +3. Reaplicar `chown -R 1000:1000 /home/renan/apps/sistema` e re-symlink `/apps/sistema`. +4. Forçar rolling update nos serviços da VPS nova: + +``` +ssh root@154.12.253.40 'docker service update --force sistema_convex_backend sistema_web' +``` + +### 3.6. DNS + +- Criado `vps2.esdrasrenan.com.br` (A → 154.12.253.40). +- Atualizados os CNAMEs `tickets.esdrasrenan.com.br` e `convex.esdrasrenan.com.br` para apontar ao alias novo (TTL efetivo 300 s após propagação). +- Observação: durante ~4 h alguns clientes ainda enxergaram o IP antigo porque o TTL prévio era 14.400 s; na próxima migração, reduzir o TTL horas antes do corte para evitar 404 temporário. + +### 3.7. Imagem Bun com OpenSSL + +Para eliminar o warning do Prisma, foi criada uma imagem local (`sistema-web:openssl-20251115`) adicionando `apt-get install openssl`. Atualização do serviço: + +``` +ssh root@154.12.253.40 'docker build -t sistema-web:openssl-20251115 - <<"EOF" +FROM oven/bun:1.3.1 +RUN apt-get update && apt-get install -y --no-install-recommends openssl \n && rm -rf /var/lib/apt/lists/* +EOF' + +docker service update --image sistema-web:openssl-20251115 sistema_web +``` + +**Atenção**: essa imagem só existe localmente; se outro nó entrar no Swarm, publique-a em um registry ou reconstrua no novo nó antes de escalar o serviço. + +## 4. Verificações pós-migração + +- `docker service ls | grep sistema` → `convex_backend 1/1`, `web 1/1`, `dashboard 0/0`. +- `curl -k -I --resolve tickets.esdrasrenan.com.br:443:154.12.253.40 https://tickets.esdrasrenan.com.br` → 307 `/login`. +- `curl -k -I --resolve convex.esdrasrenan.com.br:443:154.12.253.40 https://convex.esdrasrenan.com.br` → 200. +- `docker logs ` sem warnings de OpenSSL. +- `dig @1.1.1.1 +short tickets.esdrasrenan.com.br` → `vps2.esdrasrenan.com.br` → `154.12.253.40`. + +## 5. Recomendações para futuras trocas + +1. **TTL primeiro, migração depois**: reduza CNAMEs para 300 s pelo menos 4 h antes de alterar IPs. +2. **Dashboard opcional**: mantenha `sistema_convex_dashboard` em `0/0` até precisar, escalando com `docker service scale sistema_convex_dashboard=1`. +3. **Imagem Bun custom**: mantenha o Dockerfile acima em repositório (ex.: `containers/sistema-web/Dockerfile`) para reconstruções reproduzíveis. +4. **Rollback**: para voltar à VPS antiga, restaure o DNS (CNAME → `vps.esdrasrenan.com.br`) e escale os serviços antigos para `1`. Guarde a VPS por 24–48 h antes de desativá-la definitivamente. +5. **Logs**: monitore `docker service logs sistema_convex_backend` para garantir que não haja `exit 137`. Se reaparecer, mantenha o limite de memória ≥ 12 GB ou ajuste conforme uso real. + +Com este fluxo documentado, repetir ou reverter a migração passa a ser um processo guiado e audível. diff --git a/src/app/admin/custom-fields/page.tsx b/src/app/admin/custom-fields/page.tsx new file mode 100644 index 0000000..83ad676 --- /dev/null +++ b/src/app/admin/custom-fields/page.tsx @@ -0,0 +1,25 @@ +import { FieldsManager } from "@/components/admin/fields/fields-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function AdminCustomFieldsPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/app/admin/fields/page.tsx b/src/app/admin/fields/page.tsx index 6666ace..b9f0286 100644 --- a/src/app/admin/fields/page.tsx +++ b/src/app/admin/fields/page.tsx @@ -1,5 +1,4 @@ import { CategoriesManager } from "@/components/admin/categories/categories-manager" -import { FieldsManager } from "@/components/admin/fields/fields-manager" import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager" import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" @@ -11,15 +10,14 @@ export default function AdminFieldsPage() { } >
-
) diff --git a/src/components/admin/fields/fields-manager.tsx b/src/components/admin/fields/fields-manager.tsx index 0a492e6..611cd8e 100644 --- a/src/components/admin/fields/fields-manager.tsx +++ b/src/components/admin/fields/fields-manager.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react" +import { ArrowDown, ArrowUp } from "lucide-react" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { useAuth } from "@/lib/auth-client" @@ -113,6 +114,7 @@ export function FieldsManager() { const [editingField, setEditingField] = useState(null) const [editingScope, setEditingScope] = useState("all") const [editingCompanySelection, setEditingCompanySelection] = useState("all") + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const totals = useMemo(() => { if (!fields) return { total: 0, required: 0, select: 0 } @@ -134,6 +136,11 @@ export function FieldsManager() { setEditingCompanySelection("all") } + const closeCreateDialog = () => { + setIsCreateDialogOpen(false) + resetForm() + } + const normalizeOptions = (source: FieldOption[]) => source .map((option) => ({ @@ -170,7 +177,7 @@ export function FieldsManager() { companyId: companyIdValue, }) toast.success("Campo criado", { id: "field" }) - resetForm() + closeCreateDialog() } catch (error) { console.error(error) toast.error("Não foi possível criar o campo", { id: "field" }) @@ -336,128 +343,16 @@ export function FieldsManager() { Capture informações específicas do seu fluxo de atendimento. - -
-
-
- - setLabel(event.target.value)} - required - /> -
-
- - -
-
- setRequired(Boolean(value))} /> - -
-
- - -
-
- - setCompanySelection(value ?? "all")} - options={companyComboboxOptions} - placeholder="Todas as empresas" - renderValue={(option) => - option ? ( - {option.label} - ) : ( - Todas as empresas - ) - } - /> -

- Selecione uma empresa para tornar este campo exclusivo dela. Sem seleção, o campo aparecerá em todos os tickets. -

-
-
-
-
- -