Compare commits

..

No commits in common. "main" and "v.0.1.9" have entirely different histories.

200 changed files with 2944 additions and 21443 deletions

View file

@ -29,63 +29,7 @@
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(cargo check:*)",
"Bash(bun run:*)",
"Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")",
"Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")",
"Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")",
"Bash(cmd /c \"echo %TEMP%\")",
"Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")",
"Bash(where:*)",
"Bash(ssh-keygen:*)",
"Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)",
"Bash(npx convex deploy:*)",
"Bash(dir \"%LOCALAPPDATA%\\Raven\")",
"Bash(dir \"%APPDATA%\\Raven\")",
"Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")",
"Bash(dir \"%APPDATA%\\com.raven.app\")",
"Bash(tasklist:*)",
"Bash(dir /s /b %LOCALAPPDATA%*raven*)",
"Bash(cmd /c \"tasklist | findstr /i raven\")",
"Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")",
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")",
"Bash(node:*)",
"Bash(bun scripts/test-all-emails.tsx:*)",
"Bash(bun scripts/send-test-react-email.tsx:*)",
"Bash(dir:*)",
"Bash(git reset:*)",
"Bash(npx convex:*)",
"Bash(bun tsc:*)",
"Bash(scp:*)",
"Bash(docker run:*)",
"Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")",
"Bash(cmd /c \"docker --version && docker ps -a\")",
"Bash(powershell -Command \"docker --version\")",
"Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)",
"Bash(bunx prisma migrate:*)",
"Bash(bunx prisma db push:*)",
"Bash(bun run auth:seed:*)",
"Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)",
"Bash(bun tsx:*)",
"Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
"Bash(timeout 90 git push:*)",
"Bash(docker ps:*)",
"Bash(docker start:*)",
"Bash(docker inspect:*)",
"Bash(docker exec:*)",
"Bash(timeout 90 git push)",
"Bash(bun test:*)",
"Bash(git restore:*)",
"Bash(cd:*)",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\\src\\components\\ui\" /b)",
"Bash(timeout 120 bun:*)",
"Bash(bun run tauri:build:*)",
"Bash(git remote:*)",
"Bash(powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:/Projetos IA/sistema-de-chamados/scripts/test-windows-collection.ps1\")"
"Bash(bun run:*)"
]
}
}

View file

@ -19,9 +19,8 @@ REPORTS_CRON_SECRET=reports-cron-secret
# Diretório para arquivamento local de tickets (JSONL/backup)
ARCHIVE_DIR=./archives
# PostgreSQL database (versao 18)
# Para desenvolvimento local, use Docker:
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
# PostgreSQL database
# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
# SMTP Configuration (production values in docs/SMTP.md)

View file

@ -1,492 +0,0 @@
name: CI/CD Web + Desktop
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
force_web_deploy:
description: 'Forcar deploy do Web (ignorar filtro)?'
type: boolean
required: false
default: false
force_convex_deploy:
description: 'Forcar deploy do Convex (ignorar filtro)?'
type: boolean
required: false
default: false
env:
APP_DIR: /srv/apps/sistema
VPS_UPDATES_DIR: /var/www/updates
jobs:
changes:
name: Detect changes
runs-on: [ self-hosted, linux, vps ]
timeout-minutes: 5
outputs:
convex: ${{ steps.filter.outputs.convex }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Paths filter
id: filter
uses: https://github.com/dorny/paths-filter@v3
with:
filters: |
convex:
- 'convex/**'
web:
- 'src/**'
- 'public/**'
- 'prisma/**'
- 'next.config.ts'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
- 'middleware.ts'
- 'stack.yml'
deploy:
name: Deploy (VPS Linux)
needs: changes
timeout-minutes: 30
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/web.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
EXCLUDE_ENV=""
fi
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--filter='protect .env' \
--filter='protect .env*' \
--filter='protect apps/desktop/.env*' \
--filter='protect convex/.env*' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
$EXCLUDE_ENV \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Copy production .env if present
run: |
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
fi
- name: Ensure Next.js cache directory exists and is writable
run: |
cd "$EFFECTIVE_APP_DIR"
mkdir -p .next/cache
chmod -R u+rwX .next || true
- name: Cache Next.js build cache (.next/cache)
uses: https://github.com/actions/cache@v4
with:
path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
${{ runner.os }}-nextjs-
- name: Lint check (fail fast before build)
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint"
- name: Install and build (Next.js)
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \
-e NODE_OPTIONS="--max-old-space-size=4096" \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun"
- name: Fix Docker-created file permissions
run: |
# Docker cria arquivos como root - corrigir para o usuario runner (UID 1000)
docker run --rm -v "$EFFECTIVE_APP_DIR":/target alpine:3 \
chown -R 1000:1000 /target
echo "Permissoes do build corrigidas"
- name: Atualizar symlink do APP_DIR estavel (deploy atomico)
run: |
set -euo pipefail
ROOT="$HOME/apps"
STABLE_LINK="$ROOT/sistema.current"
mkdir -p "$ROOT"
# Sanidade: se esses arquivos nao existirem, o container vai falhar no boot.
test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; }
PREV=""
if [ -L "$STABLE_LINK" ]; then
PREV="$(readlink -f "$STABLE_LINK" || true)"
fi
echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV"
ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK"
# Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta).
if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then
ln -sfn "$STABLE_LINK" "$ROOT/sistema"
fi
echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")"
- name: Swarm deploy (stack.yml)
run: |
APP_DIR_STABLE="$HOME/apps/sistema.current"
if [ ! -d "$APP_DIR_STABLE" ]; then
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
fi
cd "$APP_DIR_STABLE"
set -o allexport
if [ -f .env ]; then
echo "Loading .env from $APP_DIR_STABLE"
. ./.env
else
echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!"
fi
set +o allexport
echo "Using APP_DIR (stable)=$APP_DIR_STABLE"
echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-<not set>}"
echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-<not set>}"
APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
- name: Wait for services to be healthy
run: |
echo "Aguardando servicos ficarem saudaveis..."
for i in $(seq 1 18); do
WEB_STATUS=$(docker service ls --filter "name=sistema_web" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
CONVEX_STATUS=$(docker service ls --filter "name=sistema_convex_backend" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
echo "Tentativa $i/18: web=$WEB_STATUS convex=$CONVEX_STATUS"
if echo "$WEB_STATUS" | grep -q "2/2" && echo "$CONVEX_STATUS" | grep -q "1/1"; then
echo "Todos os servicos estao saudaveis!"
exit 0
fi
sleep 10
done
echo "ERRO: Timeout aguardando servicos. Status atual:"
docker service ls --filter "label=com.docker.stack.namespace=sistema" || true
docker service ps sistema_web --no-trunc || true
docker service logs sistema_web --since 5m --raw 2>/dev/null | tail -n 200 || true
if [ -n "${PREV_APP_DIR:-}" ]; then
echo "Rollback: revertendo APP_DIR estavel para: $PREV_APP_DIR"
ln -sfn "$PREV_APP_DIR" "$HOME/apps/sistema.current"
cd "$HOME/apps/sistema.current"
set -o allexport
if [ -f .env ]; then
. ./.env
fi
set +o allexport
APP_DIR="$HOME/apps/sistema.current" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema || true
fi
exit 1
- name: Cleanup old build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='web.build.*'
ACTIVE="$(readlink -f "$HOME/apps/sistema.current" 2>/dev/null || true)"
echo "Scanning $ROOT for old $PATTERN dirs"
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then
echo "Skipping active dir (in use by APP_DIR): $dir"; continue
fi
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
echo "Disk usage (top 10 under $ROOT):"
du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true
convex_deploy:
name: Deploy Convex functions
needs: changes
timeout-minutes: 20
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }}
runs-on: [ self-hosted, linux, vps ]
env:
APP_DIR: /srv/apps/sistema
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/convex.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--exclude '.env*' \
--exclude 'apps/desktop/.env*' \
--exclude 'convex/.env*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Bring convex.json from live app if present
run: |
if [ -f "$APP_DIR/convex.json" ]; then
echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json"
cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json"
else
echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars"
fi
- name: Set Convex env vars (self-hosted)
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
run: |
set -e
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e MACHINE_PROVISIONING_SECRET \
-e MACHINE_TOKEN_TTL_MS \
-e FLEET_SYNC_SECRET \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
bunx convex env list"
- name: Prepare Convex deploy workspace
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -f .env ]; then
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
mv -f .env .env.bak
fi
mkdir -p .convex-tmp
- name: Deploy functions to Convex self-hosted
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CI=true \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; bunx convex deploy"
- name: Cleanup old convex build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='convex.build.*'
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
# NOTA: Job comentado porque nao ha runner Windows configurado.
# Descomentar quando configurar um runner com labels: [self-hosted, windows, desktop]
#
# desktop_release:
# name: Desktop Release (Windows)
# timeout-minutes: 30
# if: ${{ startsWith(github.ref, 'refs/tags/v') }}
# runs-on: [ self-hosted, windows, desktop ]
# defaults:
# run:
# working-directory: apps/desktop
# steps:
# - name: Checkout
# uses: https://github.com/actions/checkout@v4
#
# - name: Setup pnpm
# uses: https://github.com/pnpm/action-setup@v4
# with:
# version: 10.20.0
#
# - name: Setup Node.js
# uses: https://github.com/actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Install deps (desktop)
# run: pnpm install --frozen-lockfile
#
# - name: Build with Tauri
# uses: https://github.com/tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# with:
# projectPath: apps/desktop
#
# - name: Upload bundles to VPS
# run: |
# # Upload via SCP (configurar chave SSH no runner Windows)
# # scp -r src-tauri/target/release/bundle/* user@vps:/var/www/updates/
# echo "TODO: Configurar upload para VPS"

View file

@ -1,54 +0,0 @@
name: Quality Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint-test-build:
name: Lint, Test and Build
runs-on: [ self-hosted, linux, vps ]
env:
BETTER_AUTH_SECRET: test-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
BETTER_AUTH_URL: http://localhost:3000
NEXT_PUBLIC_CONVEX_URL: http://localhost:3210
DATABASE_URL: file:./prisma/db.dev.sqlite
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Next.js build cache
uses: https://github.com/actions/cache@v4
with:
path: |
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
- name: Generate Prisma client
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: bun run prisma:generate
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build:bun

1
.gitignore vendored
View file

@ -70,4 +70,3 @@ rustdesk/
# Prisma generated files
src/generated/
apps/desktop/service/target/

View file

@ -1,4 +1,4 @@
# Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled
# Runtime image with Node 22 + Bun 1.3.2 and build toolchain preinstalled
FROM node:22-bullseye-slim
ENV BUN_INSTALL=/root/.bun
@ -17,9 +17,9 @@ RUN apt-get update -y \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Bun 1.3.4
# Install Bun 1.3.2
RUN curl -fsSL https://bun.sh/install \
| bash -s -- bun-v1.3.4 \
| bash -s -- bun-v1.3.2 \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx

View file

@ -19,10 +19,10 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
- Seeds de usuários/tickets demo: `convex/seed.ts`.
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
## Stack atual (18/12/2025)
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
## Stack atual (06/11/2025)
- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback).
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
- **React / React DOM**: `19.2.1`.
- **React / React DOM**: `19.2.0`.
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
- **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS.
- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro.
@ -38,7 +38,7 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=dev-only-long-random-string
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
DATABASE_URL=file:./prisma/db.dev.sqlite
```
3. `bun run auth:seed`
4. (Opcional) `bun run queues:ensure`
@ -47,8 +47,8 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
7. Acesse `http://localhost:3000` e valide login com os usuários padrão.
### Banco de dados
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`).
- Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`).
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
### Verificações antes de PR/deploy
@ -104,12 +104,12 @@ bun run build:bun
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
docker service update --force sistema_web
```
- Resolver `P3009` (migration falhou) no PostgreSQL ativo:
- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`:
```bash
docker service scale sistema_web=0
docker run --rm -it --network traefik_public \
--env-file /home/renan/apps/sistema.current/.env \
docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \
-v /home/renan/apps/sistema.current:/app \
-v sistema_sistema_db:/app/data -w /app \
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
docker service scale sistema_web=1
```
@ -164,51 +164,8 @@ bun run build:bun
- **Docs complementares**:
- `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
- `docs/OPERATIONS.md` — runbook do Swarm.
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
## Regras de Codigo
### Tooltips Nativos do Navegador
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
```tsx
// ERRADO - causa tooltip nativo do navegador
<button title="Remover item">
<Trash2 className="size-4" />
</button>
// CORRETO - sem tooltip nativo
<button>
<Trash2 className="size-4" />
</button>
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
<Tooltip>
<TooltipTrigger asChild>
<button>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Remover item</TooltipContent>
</Tooltip>
```
**Excecoes:**
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
### Acessibilidade
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
```tsx
<button aria-label="Remover item">
<Trash2 className="size-4" />
</button>
```
---
_Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._

View file

@ -8,9 +8,7 @@
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "node ./scripts/tauri-with-stub.mjs",
"gen:icon": "node ./scripts/build-icon.mjs",
"build:service": "cd service && cargo build --release",
"build:all": "bun run build:service && bun run tauri build"
"gen:icon": "node ./scripts/build-icon.mjs"
},
"dependencies": {
"@radix-ui/react-scroll-area": "^1.2.3",
@ -21,7 +19,6 @@
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because it is too large Load diff

View file

@ -1,70 +0,0 @@
[package]
name = "raven-service"
version = "0.1.0"
description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop"
authors = ["Esdras Renan"]
edition = "2021"
[[bin]]
name = "raven-service"
path = "src/main.rs"
[dependencies]
# Windows Service
windows-service = "0.7"
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] }
# IPC via Named Pipes
interprocess = { version = "2", features = ["tokio"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Windows Registry
winreg = "0.55"
# Error handling
thiserror = "1.0"
# HTTP client (para RustDesk)
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Crypto (para RustDesk ID)
sha2 = "0.10"
# UUID para request IDs
uuid = { version = "1", features = ["v4"] }
# Parking lot para locks
parking_lot = "0.12"
# Once cell para singletons
once_cell = "1.19"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_System_Pipes",
"Win32_System_IO",
"Win32_System_SystemServices",
"Win32_Storage_FileSystem",
] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

View file

@ -1,290 +0,0 @@
//! Modulo IPC - Servidor de Named Pipes
//!
//! Implementa comunicacao entre o Raven UI e o Raven Service
//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado.
use crate::{rustdesk, usb_policy};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Debug, Error)]
pub enum IpcError {
#[error("Erro de IO: {0}")]
Io(#[from] std::io::Error),
#[error("Erro de serializacao: {0}")]
Json(#[from] serde_json::Error),
}
/// Requisicao JSON-RPC simplificada
#[derive(Debug, Deserialize)]
pub struct Request {
pub id: String,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// Resposta JSON-RPC simplificada
#[derive(Debug, Serialize)]
pub struct Response {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorResponse>,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub code: i32,
pub message: String,
}
impl Response {
pub fn success(id: String, result: serde_json::Value) -> Self {
Self {
id,
result: Some(result),
error: None,
}
}
pub fn error(id: String, code: i32, message: String) -> Self {
Self {
id,
result: None,
error: Some(ErrorResponse { code, message }),
}
}
}
/// Inicia o servidor de Named Pipes
pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> {
info!("Iniciando servidor IPC em: {}", pipe_name);
loop {
match accept_connection(pipe_name).await {
Ok(()) => {
debug!("Conexao processada com sucesso");
}
Err(e) => {
warn!("Erro ao processar conexao: {}", e);
}
}
}
}
/// Aceita uma conexao e processa requisicoes
async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> {
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows::Win32::Security::{
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
};
use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
use windows::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe,
PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION;
use windows::core::PCWSTR;
// Cria o named pipe com seguranca que permite acesso a todos os usuarios
let pipe_name_wide: Vec<u16> = pipe_name.encode_utf16().chain(std::iter::once(0)).collect();
// Cria security descriptor com DACL nulo (permite acesso a todos)
let mut sd = SECURITY_DESCRIPTOR::default();
unsafe {
let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION);
// DACL nulo = acesso irrestrito
let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false);
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
bInheritHandle: false.into(),
};
let pipe_handle = unsafe {
CreateNamedPipeW(
PCWSTR::from_raw(pipe_name_wide.as_ptr()),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
4096, // out buffer
4096, // in buffer
0, // default timeout
Some(&sa), // seguranca permissiva
)
};
// Verifica se o handle e valido
if pipe_handle == INVALID_HANDLE_VALUE {
return Err(IpcError::Io(std::io::Error::last_os_error()));
}
// Aguarda conexao de um cliente
info!("Aguardando conexao de cliente...");
let connect_result = unsafe {
ConnectNamedPipe(pipe_handle, None)
};
if let Err(e) = connect_result {
// ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado
// o que e aceitavel
let error_code = e.code().0 as u32;
if error_code != 535 {
warn!("Erro ao aguardar conexao: {:?}", e);
}
}
info!("Cliente conectado");
// Processa requisicoes do cliente
let result = process_client(pipe_handle);
// Desconecta o cliente
unsafe {
let _ = DisconnectNamedPipe(pipe_handle);
}
result
}
/// Processa requisicoes de um cliente conectado
fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> {
use std::os::windows::io::{FromRawHandle, RawHandle};
use std::fs::File;
// Cria File handle a partir do pipe
let raw_handle = pipe_handle.0 as RawHandle;
let file = unsafe { File::from_raw_handle(raw_handle) };
let reader = BufReader::new(file.try_clone()?);
let mut writer = file;
// Le linhas (cada linha e uma requisicao JSON)
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
if e.kind() == std::io::ErrorKind::BrokenPipe {
info!("Cliente desconectou");
break;
}
return Err(e.into());
}
};
if line.is_empty() {
continue;
}
debug!("Requisicao recebida: {}", line);
// Parse da requisicao
let response = match serde_json::from_str::<Request>(&line) {
Ok(request) => handle_request(request),
Err(e) => Response::error(
"unknown".to_string(),
-32700,
format!("Parse error: {}", e),
),
};
// Serializa e envia resposta
let response_json = serde_json::to_string(&response)?;
debug!("Resposta: {}", response_json);
writeln!(writer, "{}", response_json)?;
writer.flush()?;
}
// IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele
std::mem::forget(writer);
Ok(())
}
/// Processa uma requisicao e retorna a resposta
fn handle_request(request: Request) -> Response {
info!("Processando metodo: {}", request.method);
match request.method.as_str() {
"health_check" => handle_health_check(request.id),
"apply_usb_policy" => handle_apply_usb_policy(request.id, request.params),
"get_usb_policy" => handle_get_usb_policy(request.id),
"provision_rustdesk" => handle_provision_rustdesk(request.id, request.params),
"get_rustdesk_status" => handle_get_rustdesk_status(request.id),
_ => Response::error(
request.id,
-32601,
format!("Metodo nao encontrado: {}", request.method),
),
}
}
// =============================================================================
// Handlers de Requisicoes
// =============================================================================
fn handle_health_check(id: String) -> Response {
Response::success(
id,
serde_json::json!({
"status": "ok",
"service": "RavenService",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Utc::now().timestamp_millis()
}),
)
}
fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response {
let policy = match params.get("policy").and_then(|p| p.as_str()) {
Some(p) => p,
None => {
return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string())
}
};
match usb_policy::apply_policy(policy) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)),
}
}
fn handle_get_usb_policy(id: String) -> Response {
match usb_policy::get_current_policy() {
Ok(policy) => Response::success(
id,
serde_json::json!({
"policy": policy
}),
),
Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)),
}
}
fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response {
let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from);
let password = params.get("password").and_then(|p| p.as_str()).map(String::from);
let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from);
match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)),
}
}
fn handle_get_rustdesk_status(id: String) -> Response {
match rustdesk::get_status() {
Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)),
}
}

View file

@ -1,268 +0,0 @@
//! Raven Service - Servico Windows para operacoes privilegiadas
//!
//! Este servico roda como LocalSystem e executa operacoes que requerem
//! privilegios de administrador, como:
//! - Aplicar politicas de USB
//! - Provisionar e configurar RustDesk
//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE
//!
//! O app Raven UI comunica com este servico via Named Pipes.
mod ipc;
mod rustdesk;
mod usb_policy;
use std::ffi::OsString;
use std::time::Duration;
use tracing::{error, info};
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
};
const SERVICE_NAME: &str = "RavenService";
const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service";
const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)";
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
define_windows_service!(ffi_service_main, service_main);
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configura logging
init_logging();
// Verifica argumentos de linha de comando
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
"install" => {
install_service()?;
return Ok(());
}
"uninstall" => {
uninstall_service()?;
return Ok(());
}
"run" => {
// Modo de teste: roda sem registrar como servico
info!("Executando em modo de teste (nao como servico)");
run_standalone()?;
return Ok(());
}
_ => {}
}
}
// Inicia como servico Windows
info!("Iniciando Raven Service...");
service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
Ok(())
}
fn init_logging() {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
// Tenta criar diretorio de logs
let log_dir = std::env::var("PROGRAMDATA")
.map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs"))
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs"));
let _ = std::fs::create_dir_all(&log_dir);
// Arquivo de log
let log_file = log_dir.join("service.log");
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
.ok();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
if let Some(file) = file {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(file).with_ansi(false))
.init();
} else {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
}
fn service_main(arguments: Vec<OsString>) {
if let Err(e) = run_service(arguments) {
error!("Erro ao executar servico: {}", e);
}
}
fn run_service(_arguments: Vec<OsString>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Servico iniciando...");
// Canal para shutdown
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
// Registra handler de controle do servico
let shutdown_tx_clone = shutdown_tx.clone();
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
info!("Recebido comando de parada");
if let Ok(mut guard) = shutdown_tx_clone.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(());
}
}
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
})?;
// Atualiza status para Running
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico em execucao, aguardando conexoes...");
// Cria runtime Tokio
let runtime = tokio::runtime::Runtime::new()?;
// Executa servidor IPC
runtime.block_on(async {
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = async {
let _ = shutdown_rx.await;
} => {
info!("Shutdown solicitado");
}
}
});
// Atualiza status para Stopped
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico parado");
Ok(())
}
fn run_standalone() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async {
info!("Servidor IPC iniciando em modo standalone...");
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = tokio::signal::ctrl_c() => {
info!("Ctrl+C recebido, encerrando...");
}
}
});
Ok(())
}
fn install_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Instalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?;
let exe_path = std::env::current_exe()?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
// Define descricao
service.set_description(SERVICE_DESCRIPTION)?;
info!("Servico instalado com sucesso: {}", SERVICE_NAME);
println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME);
println!("Para iniciar: sc start {}", SERVICE_NAME);
Ok(())
}
fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::ServiceAccess,
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Desinstalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
)?;
// Tenta parar o servico primeiro
let status = service.query_status()?;
if status.current_state != ServiceState::Stopped {
info!("Parando servico...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(2));
}
// Remove o servico
service.delete()?;
info!("Servico desinstalado com sucesso");
println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME);
Ok(())
}

View file

@ -1,846 +0,0 @@
//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk
//!
//! Gerencia a instalacao, configuracao e provisionamento do RustDesk.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use chrono::Utc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::env;
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use thiserror::Error;
use tracing::{error, info, warn};
const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest";
const USER_AGENT: &str = "RavenService/1.0";
const SERVER_HOST: &str = "rust.rever.com.br";
const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI=";
const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI<b*34Vmx_8P";
const SERVICE_NAME: &str = "RustDesk";
const CACHE_DIR_NAME: &str = "Rever\\RustDeskCache";
const LOCAL_SERVICE_CONFIG: &str = r"C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config";
const LOCAL_SYSTEM_CONFIG: &str = r"C:\Windows\System32\config\systemprofile\AppData\Roaming\RustDesk\config";
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
const CREATE_NO_WINDOW: u32 = 0x08000000;
static PROVISION_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[derive(Debug, Error)]
pub enum RustdeskError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Release asset nao encontrado para Windows x86_64")]
AssetMissing,
#[error("Falha ao executar comando {command}: status {status:?}")]
CommandFailed { command: String, status: Option<i32> },
#[error("Falha ao detectar ID do RustDesk")]
MissingId,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskStatus {
pub installed: bool,
pub running: bool,
pub id: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Deserialize)]
struct ReleaseResponse {
tag_name: String,
assets: Vec<ReleaseAsset>,
}
/// Provisiona o RustDesk
pub fn ensure_rustdesk(
config_string: Option<&str>,
password_override: Option<&str>,
machine_id: Option<&str>,
) -> Result<RustdeskResult, RustdeskError> {
let _guard = PROVISION_MUTEX.lock();
info!("Iniciando provisionamento do RustDesk");
// Prepara ACLs dos diretorios de servico
if let Err(e) = ensure_service_profiles_writable() {
warn!("Aviso ao preparar ACL: {}", e);
}
// Le ID existente antes de qualquer limpeza
let preserved_remote_id = read_remote_id_from_profiles();
if let Some(ref id) = preserved_remote_id {
info!("ID existente preservado: {}", id);
}
let exe_path = detect_executable_path();
let (installed_version, freshly_installed) = ensure_installed(&exe_path)?;
info!(
"RustDesk {}: {}",
if freshly_installed { "instalado" } else { "ja presente" },
exe_path.display()
);
// Para processos existentes
let _ = stop_rustdesk_processes();
// Limpa perfis apenas se instalacao fresca
if freshly_installed {
let _ = purge_existing_rustdesk_profiles();
}
// Aplica configuracao
if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) {
if let Err(e) = run_with_args(&exe_path, &["--config", config]) {
warn!("Falha ao aplicar config inline: {}", e);
}
} else {
let config_path = write_config_files()?;
if let Err(e) = apply_config(&exe_path, &config_path) {
warn!("Falha ao aplicar config via CLI: {}", e);
}
}
// Define senha
let password = password_override
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
if let Err(e) = set_password(&exe_path, &password) {
warn!("Falha ao definir senha: {}", e);
} else {
let _ = ensure_password_files(&password);
let _ = propagate_password_profile();
}
// Define ID customizado
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
if !freshly_installed {
Some(existing_id.clone())
} else {
define_custom_id(&exe_path, machine_id)
}
} else {
define_custom_id(&exe_path, machine_id)
};
// Inicia servico
if let Err(e) = ensure_service_running(&exe_path) {
warn!("Falha ao iniciar servico: {}", e);
}
// Obtem ID final
let final_id = match query_id_with_retries(&exe_path, 5) {
Ok(id) => id,
Err(_) => {
read_remote_id_from_profiles()
.or_else(|| custom_id.clone())
.ok_or(RustdeskError::MissingId)?
}
};
// Garante ID em todos os arquivos
ensure_remote_id_files(&final_id);
let version = query_version(&exe_path).ok().or(installed_version);
let last_provisioned_at = Utc::now().timestamp_millis();
info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version);
Ok(RustdeskResult {
id: final_id,
password,
installed_version: version,
updated: freshly_installed,
last_provisioned_at,
})
}
/// Retorna status do RustDesk
pub fn get_status() -> Result<RustdeskStatus, RustdeskError> {
let exe_path = detect_executable_path();
let installed = exe_path.exists();
let running = if installed {
query_service_state().map(|s| s == "running").unwrap_or(false)
} else {
false
};
let id = if installed {
query_id(&exe_path).ok().or_else(read_remote_id_from_profiles)
} else {
None
};
let version = if installed {
query_version(&exe_path).ok()
} else {
None
};
Ok(RustdeskStatus {
installed,
running,
id,
version,
})
}
// =============================================================================
// Funcoes Auxiliares
// =============================================================================
fn detect_executable_path() -> PathBuf {
let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string());
Path::new(&program_files).join("RustDesk").join("rustdesk.exe")
}
fn ensure_installed(exe_path: &Path) -> Result<(Option<String>, bool), RustdeskError> {
if exe_path.exists() {
return Ok((None, false));
}
let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join(CACHE_DIR_NAME);
fs::create_dir_all(&cache_root)?;
let (installer_path, version_tag) = download_latest_installer(&cache_root)?;
run_installer(&installer_path)?;
thread::sleep(Duration::from_secs(20));
Ok((Some(version_tag), true))
}
fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> {
let client = Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(60))
.build()?;
let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?;
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with("x86_64.exe"))
.ok_or(RustdeskError::AssetMissing)?;
let target_path = cache_root.join(&asset.name);
if target_path.exists() {
return Ok((target_path, release.tag_name));
}
info!("Baixando RustDesk: {}", asset.name);
let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?;
let mut output = File::create(&target_path)?;
response.copy_to(&mut output)?;
Ok((target_path, release.tag_name))
}
fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> {
let status = hidden_command(installer_path)
.arg("--silent-install")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --silent-install", installer_path.display()),
status: status.code(),
});
}
Ok(())
}
fn program_data_config_dir() -> PathBuf {
PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join("RustDesk")
.join("config")
}
/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema
/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios
fn all_user_appdata_config_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// Enumera C:\Users\*\AppData\Roaming\RustDesk\config
let users_dir = Path::new("C:\\Users");
if let Ok(entries) = fs::read_dir(users_dir) {
for entry in entries.flatten() {
let path = entry.path();
// Ignora pastas de sistema
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" {
continue;
}
let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config");
// Verifica se o diretorio pai existe (usuario real)
if path.join("AppData").join("Roaming").exists() {
dirs.push(rustdesk_config);
}
}
}
// Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos)
if let Ok(appdata) = env::var("APPDATA") {
let path = Path::new(&appdata).join("RustDesk").join("config");
if !dirs.contains(&path) {
dirs.push(path);
}
}
dirs
}
fn service_profile_dirs() -> Vec<PathBuf> {
vec![
PathBuf::from(LOCAL_SERVICE_CONFIG),
PathBuf::from(LOCAL_SYSTEM_CONFIG),
]
}
fn remote_id_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(program_data_config_dir());
dirs.extend(service_profile_dirs());
dirs.extend(all_user_appdata_config_dirs());
dirs
}
fn write_config_files() -> Result<PathBuf, RustdeskError> {
let config_contents = format!(
r#"[options]
key = "{key}"
relay-server = "{host}"
custom-rendezvous-server = "{host}"
api-server = "https://{host}"
verification-method = "{verification}"
approve-mode = "{approve}"
"#,
host = SERVER_HOST,
key = SERVER_KEY,
verification = SECURITY_VERIFICATION_VALUE,
approve = SECURITY_APPROVE_MODE_VALUE,
);
let main_path = program_data_config_dir().join("RustDesk2.toml");
write_file(&main_path, &config_contents)?;
for service_dir in service_profile_dirs() {
let service_profile = service_dir.join("RustDesk2.toml");
let _ = write_file(&service_profile, &config_contents);
}
Ok(main_path)
}
fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(contents.as_bytes())
}
fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()])
}
fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--password", secret])
}
fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option<String> {
let value = machine_id.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() { None } else { Some(trimmed) }
})?;
let custom_id = derive_numeric_id(value);
if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() {
info!("ID deterministico definido: {}", custom_id);
Some(custom_id)
} else {
None
}
}
fn derive_numeric_id(machine_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(machine_id.as_bytes());
let hash = hasher.finalize();
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&hash[..8]);
let value = u64::from_le_bytes(bytes);
let num = (value % 900_000_000) + 100_000_000;
format!("{:09}", num)
}
fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
ensure_service_installed(exe_path)?;
let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]);
let _ = run_sc(&["start", SERVICE_NAME]);
remove_rustdesk_autorun_artifacts();
Ok(())
}
fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> {
if run_sc(&["query", SERVICE_NAME]).is_ok() {
return Ok(());
}
run_with_args(exe_path, &["--install-service"])
}
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
let _ = run_sc(&["stop", SERVICE_NAME]);
thread::sleep(Duration::from_secs(2));
let status = hidden_command("taskkill")
.args(["/F", "/T", "/IM", "rustdesk.exe"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if status.success() || matches!(status.code(), Some(128)) {
Ok(())
} else {
Err(RustdeskError::CommandFailed {
command: "taskkill".into(),
status: status.code(),
})
}
}
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
let files = [
"RustDesk.toml",
"RustDesk_local.toml",
"RustDesk2.toml",
"password",
"passwd",
"passwd.txt",
];
for dir in remote_id_directories() {
if !dir.exists() {
continue;
}
for name in files {
let path = dir.join(name);
if path.exists() {
let _ = fs::remove_file(&path);
}
}
}
Ok(())
}
fn ensure_password_files(secret: &str) -> Result<(), String> {
for dir in remote_id_directories() {
let password_path = dir.join("RustDesk.toml");
let _ = write_toml_kv(&password_path, "password", secret);
let local_path = dir.join("RustDesk_local.toml");
let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE);
let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE);
}
Ok(())
}
fn propagate_password_profile() -> io::Result<bool> {
// Encontra um diretorio de usuario que tenha arquivos de config
let user_dirs = all_user_appdata_config_dirs();
let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists());
let Some(src_dir) = src_dir else {
// Se nenhum usuario tem config, usa ProgramData como fonte
let pd = program_data_config_dir();
if !pd.join("RustDesk.toml").exists() {
return Ok(false);
}
return propagate_from_dir(&pd);
};
propagate_from_dir(src_dir)
}
fn propagate_from_dir(src_dir: &Path) -> io::Result<bool> {
let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"];
let mut propagated = false;
for filename in propagation_files {
let src_path = src_dir.join(filename);
if !src_path.exists() {
continue;
}
for dest_root in remote_id_directories() {
if dest_root == src_dir {
continue; // Nao copiar para si mesmo
}
let target_path = dest_root.join(filename);
if copy_overwrite(&src_path, &target_path).is_ok() {
propagated = true;
}
}
}
Ok(propagated)
}
fn ensure_remote_id_files(id: &str) {
for dir in remote_id_directories() {
let path = dir.join("RustDesk_local.toml");
let _ = write_remote_id_value(&path, id);
}
}
fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let replacement = format!("remote_id = '{}'\n", id);
if let Ok(existing) = fs::read_to_string(path) {
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
if line.trim_start().starts_with("remote_id") {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
} else {
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(replacement.as_bytes())
}
}
fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let sanitized = value.replace('\\', "\\\\").replace('"', "\\\"");
let replacement = format!("{key} = \"{sanitized}\"\n");
let existing = fs::read_to_string(path).unwrap_or_default();
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
}
fn read_remote_id_from_profiles() -> Option<String> {
for dir in remote_id_directories() {
for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] {
if let Some(id) = read_remote_id_file(&candidate) {
if !id.is_empty() {
return Some(id);
}
}
}
}
None
}
fn read_remote_id_file(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = parse_assignment(line, "remote_id") {
return Some(value);
}
}
None
}
fn parse_assignment(line: &str, key: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with(key) {
return None;
}
let (_, rhs) = trimmed.split_once('=')?;
let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result<String, RustdeskError> {
for attempt in 0..attempts {
match query_id(exe_path) {
Ok(value) if !value.trim().is_empty() => return Ok(value),
_ => {}
}
if attempt + 1 < attempts {
thread::sleep(Duration::from_millis(800));
}
}
Err(RustdeskError::MissingId)
}
fn query_id(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--get-id").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --get-id", exe_path.display()),
status: output.status.code(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return Err(RustdeskError::MissingId);
}
Ok(stdout)
}
fn query_version(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--version").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --version", exe_path.display()),
status: output.status.code(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn query_service_state() -> Option<String> {
let output = hidden_command("sc")
.args(["query", SERVICE_NAME])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let lower = line.to_lowercase();
if lower.contains("running") {
return Some("running".to_string());
}
if lower.contains("stopped") {
return Some("stopped".to_string());
}
}
None
}
fn run_sc(args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command("sc")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("sc {}", args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command(exe_path)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} {}", exe_path.display(), args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn remove_rustdesk_autorun_artifacts() {
// Remove atalhos de inicializacao automatica
let mut startup_paths: Vec<PathBuf> = Vec::new();
if let Ok(appdata) = env::var("APPDATA") {
startup_paths.push(
Path::new(&appdata)
.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"),
);
}
startup_paths.push(PathBuf::from(
r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk",
));
for path in startup_paths {
if path.exists() {
let _ = fs::remove_file(&path);
}
}
// Remove entradas de registro
for hive in ["HKCU", "HKLM"] {
let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive);
let _ = hidden_command("reg")
.args(["delete", &reg_path, "/v", "RustDesk", "/f"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn ensure_service_profiles_writable() -> Result<(), String> {
for dir in service_profile_dirs() {
if !can_write_dir(&dir) {
fix_profile_acl(&dir)?;
}
}
Ok(())
}
fn can_write_dir(dir: &Path) -> bool {
if fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".raven_acl_probe");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&probe)
{
Ok(mut file) => {
if file.write_all(b"ok").is_err() {
let _ = fs::remove_file(&probe);
return false;
}
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn fix_profile_acl(target: &Path) -> Result<(), String> {
let target_str = target.display().to_string();
// Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente
let _ = hidden_command("takeown")
.args(["/F", &target_str, "/R", "/D", "Y"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let status = hidden_command("icacls")
.args([
&target_str,
"/grant",
"*S-1-5-32-544:(OI)(CI)F",
"*S-1-5-19:(OI)(CI)F",
"*S-1-5-32-545:(OI)(CI)M",
"/T",
"/C",
"/Q",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("Erro ao executar icacls: {}", e))?;
if status.success() {
Ok(())
} else {
Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1)))
}
}
fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if dst.is_dir() {
fs::remove_dir_all(dst)?;
} else if dst.exists() {
fs::remove_file(dst)?;
}
fs::copy(src, dst)?;
Ok(())
}
fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut cmd = Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}

View file

@ -1,259 +0,0 @@
//! Modulo USB Policy - Controle de dispositivos USB
//!
//! Implementa o controle de armazenamento USB no Windows.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use serde::{Deserialize, Serialize};
use std::io;
use thiserror::Error;
use tracing::{error, info, warn};
use winreg::enums::*;
use winreg::RegKey;
// GUID para Removable Storage Devices (Disk)
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
// Chaves de registro
const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UsbPolicy {
Allow,
BlockAll,
Readonly,
}
impl UsbPolicy {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"ALLOW" => Some(Self::Allow),
"BLOCK_ALL" => Some(Self::BlockAll),
"READONLY" => Some(Self::Readonly),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Allow => "ALLOW",
Self::BlockAll => "BLOCK_ALL",
Self::Readonly => "READONLY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbPolicyResult {
pub success: bool,
pub policy: String,
pub error: Option<String>,
pub applied_at: Option<i64>,
}
#[derive(Error, Debug)]
pub enum UsbControlError {
#[error("Politica USB invalida: {0}")]
InvalidPolicy(String),
#[error("Erro de registro do Windows: {0}")]
RegistryError(String),
#[error("Permissao negada")]
PermissionDenied,
#[error("Erro de I/O: {0}")]
Io(#[from] io::Error),
}
/// Aplica uma politica de USB
pub fn apply_policy(policy_str: &str) -> Result<UsbPolicyResult, UsbControlError> {
let policy = UsbPolicy::from_str(policy_str)
.ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?;
let now = chrono::Utc::now().timestamp_millis();
info!("Aplicando politica USB: {:?}", policy);
// 1. Aplicar Removable Storage Policy
apply_removable_storage_policy(policy)?;
// 2. Aplicar USBSTOR
apply_usbstor_policy(policy)?;
// 3. Aplicar WriteProtect se necessario
if policy == UsbPolicy::Readonly {
apply_write_protect(true)?;
} else {
apply_write_protect(false)?;
}
// 4. Atualizar Group Policy (opcional)
if let Err(e) = refresh_group_policy() {
warn!("Falha ao atualizar group policy: {}", e);
}
info!("Politica USB aplicada com sucesso: {:?}", policy);
Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
})
}
/// Retorna a politica USB atual
pub fn get_current_policy() -> Result<String, UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
// Verifica Removable Storage Policy primeiro
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
if deny_read == 1 && deny_write == 1 {
return Ok("BLOCK_ALL".to_string());
}
if deny_read == 0 && deny_write == 1 {
return Ok("READONLY".to_string());
}
}
// Verifica USBSTOR como fallback
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
let start: u32 = key.get_value("Start").unwrap_or(3);
if start == 4 {
return Ok("BLOCK_ALL".to_string());
}
}
Ok("ALLOW".to_string())
}
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
match policy {
UsbPolicy::Allow => {
// Tenta remover as restricoes, se existirem
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
let _ = key.delete_value("Deny_Read");
let _ = key.delete_value("Deny_Write");
let _ = key.delete_value("Deny_Execute");
}
// Tenta remover a chave inteira se estiver vazia
let _ = hklm.delete_subkey(&full_path);
}
UsbPolicy::BlockAll => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
key.set_value("Deny_Read", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &1u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
// Permite leitura, bloqueia escrita
key.set_value("Deny_Read", &0u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &0u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
.map_err(map_winreg_error)?;
match policy {
UsbPolicy::Allow => {
// Start = 3 habilita o driver
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::BlockAll => {
// Start = 4 desabilita o driver
key.set_value("Start", &4u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
// Readonly mantem driver ativo
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if enable {
let (key, _) = hklm
.create_subkey(STORAGE_POLICY_PATH)
.map_err(map_winreg_error)?;
key.set_value("WriteProtect", &1u32)
.map_err(map_winreg_error)?;
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
}
Ok(())
}
fn refresh_group_policy() -> Result<(), UsbControlError> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("gpupdate")
.args(["/target:computer", "/force"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(UsbControlError::Io)?;
if !output.status.success() {
warn!(
"gpupdate retornou erro: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn map_winreg_error(error: io::Error) -> UsbControlError {
if let Some(code) = error.raw_os_error() {
if code == 5 {
return UsbControlError::PermissionDenied;
}
}
UsbControlError::RegistryError(error.to_string())
}

View file

@ -63,7 +63,6 @@ dependencies = [
"base64 0.22.1",
"chrono",
"convex",
"dirs 5.0.1",
"futures-util",
"get_if_addrs",
"hostname",
@ -81,12 +80,10 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"thiserror 1.0.69",
"tokio",
"uuid",
"winreg",
]
@ -939,34 +936,13 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
"dirs-sys",
]
[[package]]
@ -977,7 +953,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"redox_users",
"windows-sys 0.61.2",
]
@ -3651,17 +3627,6 @@ dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@ -4549,7 +4514,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs 6.0.0",
"dirs",
"dunce",
"embed_plist",
"getrandom 0.3.3",
@ -4599,7 +4564,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
dependencies = [
"anyhow",
"cargo_toml",
"dirs 6.0.0",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
@ -4783,21 +4748,6 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.0"
@ -4821,7 +4771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"dirs",
"flate2",
"futures-util",
"http",
@ -5357,7 +5307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
dependencies = [
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"libappindicator",
"muda",
"objc2 0.6.3",
@ -6138,15 +6088,6 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -6198,21 +6139,6 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -6270,12 +6196,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@ -6294,12 +6214,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@ -6318,12 +6232,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -6354,12 +6262,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@ -6378,12 +6280,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@ -6402,12 +6298,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@ -6426,12 +6316,6 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@ -6494,7 +6378,7 @@ dependencies = [
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"dpi",
"dunce",
"gdkx11",

View file

@ -26,7 +26,6 @@ tauri-plugin-updater = "2.9.0"
tauri-plugin-process = "2.3.0"
tauri-plugin-notification = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
@ -42,8 +41,6 @@ hostname = "0.4"
base64 = "0.22"
sha2 = "0.10"
convex = "0.10.2"
uuid = { version = "1", features = ["v4"] }
dirs = "5"
# SSE usa reqwest com stream, nao precisa de websocket
[target.'cfg(windows)'.dependencies]

View file

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for all windows",
"windows": ["main", "chat-*", "chat-hub"],
"windows": ["main", "chat-*"],
"permissions": [
"core:default",
"core:event:default",
@ -14,7 +14,6 @@
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"dialog:allow-open",
"opener:default",
"store:default",

View file

@ -1,121 +1,20 @@
; Hooks customizadas do instalador NSIS (Tauri)
;
; Objetivo:
; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo
; - Instalar o Raven Service para operacoes privilegiadas sem UAC
; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo.
;
; Nota: o bundler do Tauri injeta estes macros no script principal do instalador.
BrandingText " "
!macro NSIS_HOOK_PREINSTALL
; Para e remove qualquer instancia anterior do servico antes de atualizar
DetailPrint "Parando servicos anteriores..."
; Para o servico
nsExec::ExecToLog 'sc stop RavenService'
; Aguarda o servico parar completamente (ate 10 segundos)
nsExec::ExecToLog 'powershell -Command "$$i=0; while((Get-Service RavenService -ErrorAction SilentlyContinue).Status -eq \"Running\" -and $$i -lt 10){Start-Sleep 1;$$i++}"'
; Remove o servico antigo (IMPORTANTE para reinstalacoes)
DetailPrint "Removendo servico antigo..."
IfFileExists "$INSTDIR\raven-service.exe" 0 +2
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
; Fallback: remove via sc delete se o executavel nao existir
nsExec::ExecToLog 'sc delete RavenService'
; Forca encerramento de processos remanescentes
nsExec::ExecToLog 'taskkill /F /IM raven-service.exe'
nsExec::ExecToLog 'taskkill /F /IM appsdesktop.exe'
; Aguarda liberacao dos arquivos e remocao completa do servico
Sleep 3000
!macroend
!macro NSIS_HOOK_POSTINSTALL
; =========================================================================
; Instala e inicia o Raven Service
; =========================================================================
DetailPrint "Instalando Raven Service..."
; Garante que nao ha servico residual
nsExec::ExecToLog 'sc delete RavenService'
Sleep 1000
; O servico ja esta em $INSTDIR (copiado como resource pelo Tauri)
; Registra o servico Windows
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
Pop $0
${If} $0 != 0
DetailPrint "Aviso: Falha ao registrar servico (codigo: $0)"
; Tenta remover completamente e reinstalar
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
nsExec::ExecToLog 'sc delete RavenService'
Sleep 1000
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
Pop $0
${EndIf}
; Aguarda registro do servico
Sleep 500
; Inicia o servico
DetailPrint "Iniciando Raven Service..."
nsExec::ExecToLog 'sc start RavenService'
Pop $0
${If} $0 == 0
DetailPrint "Raven Service iniciado com sucesso!"
${Else}
; Tenta novamente apos breve espera
Sleep 1000
nsExec::ExecToLog 'sc start RavenService'
Pop $0
${If} $0 == 0
DetailPrint "Raven Service iniciado com sucesso (segunda tentativa)!"
${Else}
DetailPrint "Aviso: Servico sera iniciado na proxima reinicializacao (codigo: $0)"
${EndIf}
${EndIf}
; =========================================================================
; Verifica se RustDesk esta instalado
; Se nao estiver, o Raven Service instalara automaticamente no primeiro uso
; =========================================================================
IfFileExists "$PROGRAMFILES\RustDesk\rustdesk.exe" rustdesk_found rustdesk_not_found
rustdesk_not_found:
DetailPrint "RustDesk sera instalado automaticamente pelo Raven Service."
Goto rustdesk_done
rustdesk_found:
DetailPrint "RustDesk ja esta instalado."
rustdesk_done:
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; =========================================================================
; Para e remove o Raven Service
; =========================================================================
DetailPrint "Parando Raven Service..."
nsExec::ExecToLog 'sc stop RavenService'
Sleep 1000
DetailPrint "Removendo Raven Service..."
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
; Aguarda um pouco para garantir que o servico foi removido
Sleep 500
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
; Nada adicional necessario
!macroend

View file

@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value {
}
fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> {
if !bytes.len().is_multiple_of(2) {
if bytes.len() % 2 != 0 {
return None;
}
let utf16: Vec<u16> = bytes
@ -971,169 +971,6 @@ fn collect_windows_extended() -> serde_json::Value {
"#).unwrap_or_else(|| json!([]));
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
// Bateria (notebooks/laptops)
let battery = ps(r#"
$batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime)
if ($batteries.Count -eq 0) {
[PSCustomObject]@{ Present = $false; Batteries = @() }
} else {
# Mapeia status numérico para texto
$statusMap = @{
1 = 'Discharging'
2 = 'AC Power'
3 = 'Fully Charged'
4 = 'Low'
5 = 'Critical'
6 = 'Charging'
7 = 'Charging High'
8 = 'Charging Low'
9 = 'Charging Critical'
10 = 'Undefined'
11 = 'Partially Charged'
}
foreach ($b in $batteries) {
if ($b.BatteryStatus) {
$b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force
}
}
[PSCustomObject]@{ Present = $true; Batteries = $batteries }
}
"#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] }));
// Sensores térmicos (temperatura CPU/GPU quando disponível)
let thermal = ps(r#"
$temps = @()
# Tenta WMI thermal zone (requer admin em alguns sistemas)
try {
$zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
foreach ($z in $zones) {
if ($z.CurrentTemperature) {
$celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1)
$temps += [PSCustomObject]@{
Source = 'ThermalZone'
Name = $z.InstanceName
TemperatureCelsius = $celsius
CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null }
}
}
}
} catch {}
# CPU temp via Open Hardware Monitor WMI (se instalado)
try {
$ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' }
foreach ($s in $ohm) {
$temps += [PSCustomObject]@{
Source = 'OpenHardwareMonitor'
Name = $s.Name
TemperatureCelsius = $s.Value
Parent = $s.Parent
}
}
} catch {}
@($temps)
"#).unwrap_or_else(|| json!([]));
// Adaptadores de rede (físicos e virtuais)
let network_adapters = ps(r#"
@(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object {
$statusMap = @{
0 = 'Disconnected'
1 = 'Connecting'
2 = 'Connected'
3 = 'Disconnecting'
4 = 'Hardware not present'
5 = 'Hardware disabled'
6 = 'Hardware malfunction'
7 = 'Media disconnected'
8 = 'Authenticating'
9 = 'Authentication succeeded'
10 = 'Authentication failed'
11 = 'Invalid address'
12 = 'Credentials required'
}
$_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force
$_
})
"#).unwrap_or_else(|| json!([]));
// Monitores conectados
let monitors = ps(r#"
@(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object {
$decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } }
[PSCustomObject]@{
ManufacturerName = & $decode $_.ManufacturerName
ProductCodeID = & $decode $_.ProductCodeID
SerialNumberID = & $decode $_.SerialNumberID
UserFriendlyName = & $decode $_.UserFriendlyName
YearOfManufacture = $_.YearOfManufacture
WeekOfManufacture = $_.WeekOfManufacture
}
})
"#).unwrap_or_else(|| json!([]));
// Fonte de alimentação / chassis
let power_supply = ps(r#"
$chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag
$chassisTypeMap = @{
1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop'
5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable'
9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station'
13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box'
17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis'
20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis'
23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis'
30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable'
}
$types = @()
if ($chassis.ChassisTypes) {
foreach ($t in $chassis.ChassisTypes) {
$types += $chassisTypeMap[[int]$t] ?? "Type$t"
}
}
[PSCustomObject]@{
ChassisTypes = $chassis.ChassisTypes
ChassisTypesText = $types
Manufacturer = $chassis.Manufacturer
SerialNumber = $chassis.SerialNumber
SMBIOSAssetTag = $chassis.SMBIOSAssetTag
}
"#).unwrap_or_else(|| json!({}));
// Último reinício e contagem de boots
let boot_info = ps(r#"
$os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime
$lastBoot = $os.LastBootUpTime
# Calcula uptime
$uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 }
# Conta eventos de boot (ID 6005) - últimos 30 dias para performance
$startDate = (Get-Date).AddDays(-30)
$bootEvents = @()
$bootCount = 0
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'System'
ID = 6005
StartTime = $startDate
} -MaxEvents 50 -ErrorAction SilentlyContinue
$bootCount = @($events).Count
$bootEvents = @($events | Select-Object -First 10 | ForEach-Object {
@{
TimeCreated = $_.TimeCreated.ToString('o')
Computer = $_.MachineName
}
})
} catch {}
[PSCustomObject]@{
LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null }
UptimeSeconds = [math]::Round($uptime)
BootCountLast30Days = $bootCount
RecentBoots = $bootEvents
}
"#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] }));
json!({
"windows": {
"software": software,
@ -1155,12 +992,6 @@ fn collect_windows_extended() -> serde_json::Value {
"windowsUpdate": windows_update,
"computerSystem": computer_system,
"azureAdStatus": device_join,
"battery": battery,
"thermal": thermal,
"networkAdapters": network_adapters,
"monitors": monitors,
"chassis": power_supply,
"bootInfo": boot_info,
}
})
}
@ -1255,7 +1086,7 @@ pub fn collect_profile() -> Result<MachineProfile, AgentError> {
let system = collect_system();
let os_name = System::name()
.or_else(System::long_os_version)
.or_else(|| System::long_os_version())
.unwrap_or_else(|| "desconhecido".to_string());
let os_version = System::os_version();
let architecture = std::env::consts::ARCH.to_string();
@ -1315,7 +1146,7 @@ async fn post_heartbeat(
.into_owned();
let os = MachineOs {
name: System::name()
.or_else(System::long_os_version)
.or_else(|| System::long_os_version())
.unwrap_or_else(|| "desconhecido".to_string()),
version: System::os_version(),
architecture: Some(std::env::consts::ARCH.to_string()),
@ -1394,8 +1225,7 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
#[cfg(target_os = "windows")]
{
use crate::usb_control::{get_current_policy, UsbPolicy};
use crate::service_client;
use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy};
let policy = match UsbPolicy::from_str(&policy_str) {
Some(p) => p,
@ -1429,58 +1259,24 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
// Reporta APPLYING para progress bar real no frontend
let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await;
// Tenta primeiro via RavenService (privilegiado)
crate::log_info!("Tentando aplicar politica via RavenService...");
match service_client::apply_usb_policy(&policy_str) {
match apply_usb_policy(policy) {
Ok(result) => {
if result.success {
crate::log_info!("Politica USB aplicada com sucesso via RavenService: {:?}", result);
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
if !reported {
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::log_info!("Retry agendado: reportando politica USB...");
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
});
}
return;
} else {
let err_msg = result.error.unwrap_or_else(|| "Erro desconhecido".to_string());
crate::log_error!("RavenService retornou erro: {}", err_msg);
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
}
}
Err(service_client::ServiceClientError::ServiceUnavailable(msg)) => {
crate::log_warn!("RavenService nao disponivel: {}", msg);
// Tenta fallback direto (vai falhar se nao tiver privilegio)
crate::log_info!("Tentando aplicar politica diretamente...");
match crate::usb_control::apply_usb_policy(policy) {
Ok(result) => {
crate::log_info!("Politica USB aplicada com sucesso (direto): {:?}", result);
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
if !reported {
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::log_info!("Retry agendado: reportando politica USB...");
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
});
}
}
Err(e) => {
let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e);
crate::log_error!("{}", err_msg);
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
}
crate::log_info!("Politica USB aplicada com sucesso: {:?}", result);
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
if !reported {
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
// Agenda retry em background
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::log_info!("Retry agendado: reportando politica USB...");
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
});
}
}
Err(e) => {
crate::log_error!("Falha ao comunicar com RavenService: {e}");
crate::log_error!("Falha ao aplicar politica USB: {e}");
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
}
}

View file

@ -10,12 +10,10 @@ use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant};
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt;
@ -102,77 +100,6 @@ pub struct SessionStartedEvent {
pub session: ChatSession,
}
// ============================================================================
// PERSISTENCIA DE ESTADO
// ============================================================================
/// Estado persistido do chat para sobreviver a restarts
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChatPersistedState {
last_unread_count: u32,
sessions: Vec<ChatSession>,
saved_at: u64, // Unix timestamp em ms
}
const STATE_FILE_NAME: &str = "chat-state.json";
const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos
fn get_state_file_path() -> Option<PathBuf> {
dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME))
}
fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) {
let Some(path) = get_state_file_path() else {
return;
};
// Criar diretorio se nao existir
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let state = ChatPersistedState {
last_unread_count: last_unread,
sessions: sessions.to_vec(),
saved_at: now,
};
if let Ok(json) = serde_json::to_string_pretty(&state) {
let _ = fs::write(&path, json);
crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len());
}
}
fn load_chat_state() -> Option<ChatPersistedState> {
let path = get_state_file_path()?;
let json = fs::read_to_string(&path).ok()?;
let state: ChatPersistedState = serde_json::from_str(&json).ok()?;
// Verificar se estado nao esta muito antigo
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS {
crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)");
return None;
}
crate::log_info!(
"[CHAT] Estado restaurado: unread={}, sessions={}",
state.last_unread_count, state.sessions.len()
);
Some(state)
}
// ============================================================================
// HTTP CLIENT
// ============================================================================
@ -394,7 +321,6 @@ pub struct UploadResult {
// Extensoes permitidas
const ALLOWED_EXTENSIONS: &[&str] = &[
".jpg", ".jpeg", ".png", ".gif", ".webp",
".mp3", ".wav", ".ogg", ".webm", ".m4a",
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
];
@ -435,11 +361,6 @@ pub fn get_mime_type(file_name: &str) -> String {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"webm" => "audio/webm",
"m4a" => "audio/mp4",
"pdf" => "application/pdf",
"txt" => "text/plain",
"doc" => "application/msword",
@ -541,16 +462,10 @@ pub struct ChatRuntime {
impl ChatRuntime {
pub fn new() -> Self {
// Tentar restaurar estado persistido
let (sessions, unread) = match load_chat_state() {
Some(state) => (state.sessions, state.last_unread_count),
None => (Vec::new(), 0),
};
Self {
inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(sessions)),
last_unread_count: Arc::new(Mutex::new(unread)),
last_sessions: Arc::new(Mutex::new(Vec::new())),
last_unread_count: Arc::new(Mutex::new(0)),
is_connected: Arc::new(AtomicBool::new(false)),
}
}
@ -595,9 +510,7 @@ impl ChatRuntime {
let is_connected = self.is_connected.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat");
crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone);
crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone);
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)");
let mut backoff_ms: u64 = 1_000;
let max_backoff_ms: u64 = 30_000;
@ -609,16 +522,12 @@ impl ChatRuntime {
break;
}
crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex...");
let client_result = ConvexClient::new(&convex_clone).await;
let mut client = match client_result {
Ok(c) => {
crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso");
c
}
Ok(c) => c,
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}");
crate::log_warn!("Falha ao criar cliente Convex: {err:?}");
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
@ -641,18 +550,16 @@ impl ChatRuntime {
let mut args = BTreeMap::new();
args.insert("machineToken".to_string(), token_clone.clone().into());
crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates...");
let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await;
let mut subscription = match subscribe_result {
Ok(sub) => {
is_connected.store(true, Ordering::Relaxed);
backoff_ms = 1_000;
crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!");
sub
}
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}");
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}");
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
@ -672,12 +579,8 @@ impl ChatRuntime {
}
};
crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket...");
let mut update_count: u64 = 0;
while let Some(next) = subscription.next().await {
update_count += 1;
if stop_clone.load(Ordering::Relaxed) {
crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop");
break;
}
match next {
@ -698,11 +601,6 @@ impl ChatRuntime {
})
.unwrap_or(0);
crate::log_info!(
"[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}",
update_count, has_active, total_unread
);
process_chat_update(
&base_clone,
&token_clone,
@ -715,13 +613,13 @@ impl ChatRuntime {
.await;
}
FunctionResult::ConvexError(err) => {
crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}");
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}");
}
FunctionResult::ErrorMessage(msg) => {
crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}");
crate::log_warn!("Erro em checkMachineUpdates: {msg}");
}
FunctionResult::Value(other) => {
crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}");
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}");
}
}
}
@ -729,11 +627,10 @@ impl ChatRuntime {
is_connected.store(false, Ordering::Relaxed);
if stop_clone.load(Ordering::Relaxed) {
crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop");
break;
}
crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar...");
crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar");
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
&base_clone,
@ -787,13 +684,8 @@ async fn poll_and_process_chat_update(
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>,
) {
crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling...");
match poll_chat_updates(base_url, token, None).await {
Ok(result) => {
crate::log_info!(
"[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}",
result.has_active_sessions, result.total_unread
);
process_chat_update(
base_url,
token,
@ -806,7 +698,7 @@ async fn poll_and_process_chat_update(
.await;
}
Err(err) => {
crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}");
crate::log_warn!("Chat fallback poll falhou: {err}");
}
}
}
@ -820,18 +712,10 @@ async fn process_chat_update(
has_active_sessions: bool,
total_unread: u32,
) {
crate::log_info!(
"[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}",
has_active_sessions, total_unread
);
// Buscar sessoes completas para ter dados corretos
let mut current_sessions = if has_active_sessions {
let sessions = fetch_sessions(base_url, token).await.unwrap_or_default();
crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len());
sessions
fetch_sessions(base_url, token).await.unwrap_or_default()
} else {
crate::log_info!("[CHAT DEBUG] Sem sessoes ativas");
Vec::new()
};
@ -892,57 +776,13 @@ async fn process_chat_update(
}
}
// =========================================================================
// DETECCAO ROBUSTA DE NOVAS MENSAGENS
// Usa DUAS estrategias: timestamp E contador (belt and suspenders)
// =========================================================================
let prev_unread = *last_unread_count.lock();
// Estrategia 1: Detectar por lastActivityAt de cada sessao
// Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem
let mut detected_by_activity = false;
let mut activity_details = String::new();
for session in &current_sessions {
let prev_activity = prev_sessions
.iter()
.find(|s| s.session_id == session.session_id)
.map(|s| s.last_activity_at)
.unwrap_or(0);
// Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente
if session.last_activity_at > prev_activity && session.unread_count > 0 {
detected_by_activity = true;
activity_details = format!(
"sessao={} activity: {} -> {} unread={}",
session.ticket_id, prev_activity, session.last_activity_at, session.unread_count
);
break;
}
}
// Estrategia 2: Fallback por contador total (metodo original)
let detected_by_count = total_unread > prev_unread;
// Nova mensagem se QUALQUER estrategia detectar
let new_messages = detected_by_activity || detected_by_count;
// Log detalhado para diagnostico
crate::log_info!(
"[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}",
detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages
);
if detected_by_activity {
crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details);
}
// Atualizar caches APOS deteccao (importante: manter ordem)
// Atualizar cache de sessoes
*last_sessions.lock() = current_sessions.clone();
*last_unread_count.lock() = total_unread;
// Persistir estado para sobreviver a restarts
save_chat_state(total_unread, &current_sessions);
// Verificar mensagens nao lidas
let prev_unread = *last_unread_count.lock();
let new_messages = total_unread > prev_unread;
*last_unread_count.lock() = total_unread;
// Sempre emitir unread-update
let _ = app.emit(
@ -953,25 +793,11 @@ async fn process_chat_update(
}),
);
if current_sessions.is_empty() {
close_all_chat_windows(app);
let _ = close_hub_window(app);
return;
}
// Notificar novas mensagens - mostrar chat minimizado com badge
if new_messages && total_unread > 0 {
let new_count = if total_unread > prev_unread {
total_unread - prev_unread
} else {
1 // Se detectou por activity mas contador nao mudou, assumir 1 nova
};
let new_count = total_unread - prev_unread;
crate::log_info!(
"[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}",
new_count, total_unread,
if detected_by_activity { "activity" } else { "count" }
);
crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread);
let _ = app.emit(
"raven://chat/new-message",
@ -1012,34 +838,37 @@ async fn process_chat_update(
}
}
// Se ha multiplas sessoes ativas, usar o hub quando nao houver chat expandido.
//
// Importante (UX): nao mostrar hub e chat ao mesmo tempo.
if current_sessions.len() > 1 {
if has_expanded_chat_window() {
let _ = close_hub_window(app);
} else {
close_all_chat_windows(app);
let _ = open_hub_window(app);
}
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
let session_to_show = if best_delta > 0 {
best_session
} else {
// Uma sessao - nao precisa de hub
let _ = close_hub_window(app);
current_sessions.iter().max_by(|a, b| {
a.unread_count
.cmp(&b.unread_count)
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
})
};
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
let session_to_show = if best_delta > 0 {
best_session
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra)
if let Some(session) = session_to_show {
let label = format!("chat-{}", session.ticket_id);
if let Some(window) = app.get_webview_window(&label) {
// Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida)
// Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens
let _ = window.show();
// Verificar se esta expandida (altura > 100px significa expandido)
// Se estiver expandida, NAO minimizar - usuario esta usando o chat
if let Ok(size) = window.inner_size() {
let is_expanded = size.height > 100;
if !is_expanded {
// Janela esta minimizada, manter minimizada
let _ = set_chat_minimized(app, &session.ticket_id, true);
}
// Se esta expandida, nao faz nada - deixa o usuario continuar usando
}
} else {
current_sessions.iter().max_by(|a, b| {
a.unread_count
.cmp(&b.unread_count)
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
})
};
// Mostrar janela de chat (sempre minimizada/nao intrusiva)
if let Some(session) = session_to_show {
let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true);
// Criar nova janela ja minimizada (menos intrusivo)
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
}
}
@ -1056,16 +885,6 @@ async fn process_chat_update(
.title(notification_title)
.body(&notification_body)
.show();
} else {
// Log para debug quando NAO ha novas mensagens
if total_unread == 0 {
crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)");
} else if !new_messages {
crate::log_info!(
"[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})",
prev_unread, total_unread
);
}
}
}
@ -1073,53 +892,6 @@ async fn process_chat_update(
// WINDOW MANAGEMENT
// ============================================================================
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
static CHAT_WINDOW_STATE: Lazy<Mutex<HashMap<String, bool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
fn set_chat_window_state(label: &str, minimized: bool) {
CHAT_WINDOW_STATE.lock().insert(label.to_string(), minimized);
}
fn clear_chat_window_state(label: &str) {
CHAT_WINDOW_STATE.lock().remove(label);
}
fn has_expanded_chat_window() -> bool {
CHAT_WINDOW_STATE.lock().values().any(|minimized| !*minimized)
}
fn close_all_chat_windows(app: &tauri::AppHandle) {
let labels: Vec<String> = app
.webview_windows()
.keys()
.filter(|label| label.starts_with("chat-") && *label != HUB_WINDOW_LABEL)
.cloned()
.collect();
for label in labels {
if let Some(window) = app.get_webview_window(&label) {
let _ = window.close();
}
clear_chat_window_state(&label);
}
}
fn hide_other_chat_windows(app: &tauri::AppHandle, active_label: &str) {
for (label, window) in app.webview_windows() {
if !label.starts_with("chat-") {
continue;
}
if label == active_label {
continue;
}
let _ = window.hide();
set_chat_window_state(&label, true);
}
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = hub.hide();
}
}
fn resolve_chat_window_position(
app: &tauri::AppHandle,
window: Option<&tauri::WebviewWindow>,
@ -1160,44 +932,18 @@ fn resolve_chat_window_position(
(x, y)
}
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada
}
/// Abre janela de chat com estado inicial de minimizacao configuravel
fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
crate::log_info!(
"[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}",
label,
ticket_ref,
start_minimized
);
if !start_minimized {
hide_other_chat_windows(app, &label);
}
// Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) {
let _ = window.set_ignore_cursor_events(false);
crate::log_info!("[WINDOW] {}: window existe -> show()", label);
window.show().map_err(|e| e.to_string())?;
let _ = window.unminimize();
if !start_minimized {
crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label);
window.set_focus().map_err(|e| e.to_string())?;
}
// Expandir a janela se estiver minimizada (quando clicado na lista)
if !start_minimized {
crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label);
let _ = set_chat_minimized_unlocked(app, ticket_id, false);
}
crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label);
if !start_minimized {
set_chat_window_state(&label, false);
}
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
@ -1214,17 +960,7 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
// Usar query param ao inves de path para compatibilidade com SPA
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
crate::log_info!(
"[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}",
label,
width,
height,
x,
y,
url_path
);
let window = WebviewWindowBuilder::new(
WebviewWindowBuilder::new(
app,
&label,
WebviewUrl::App(url_path.into()),
@ -1236,64 +972,46 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
.decorations(false) // Sem decoracoes nativas - usa header customizado
.transparent(true) // Permite fundo transparente
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
.resizable(false) // Desabilitar redimensionamento manual
// Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
.always_on_top(true)
.skip_taskbar(true)
.focused(!start_minimized)
.focused(true)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: build() OK", label);
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
let _ = window.set_ignore_cursor_events(false);
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized);
// Reaplica layout/posicao logo apos criar a janela.
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized);
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized);
let _ = set_chat_minimized(app, ticket_id, start_minimized);
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
Ok(())
}
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
// Quando chamado explicitamente (ex: clique no hub), abre expandida
open_chat_window_internal(app, ticket_id, ticket_ref, false)
open_chat_window_internal(app, ticket_id, ticket_ref)
}
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) {
window.close().map_err(|e| e.to_string())?;
}
clear_chat_window_state(&label);
Ok(())
}
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) {
window.hide().map_err(|e| e.to_string())?;
}
set_chat_window_state(&label, true);
Ok(())
}
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?;
if minimized {
hide_other_chat_windows(app, &label);
}
// Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1)
let (width, height) = if minimized {
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
@ -1305,125 +1023,9 @@ fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimize
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// Aplicar novo tamanho e posicao
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized);
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized);
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized);
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized);
set_chat_window_state(&label, minimized);
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
Ok(())
}
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
set_chat_minimized_unlocked(app, ticket_id, minimized)
}
// ============================================================================
// HUB WINDOW MANAGEMENT (Lista de todas as sessoes)
// ============================================================================
const HUB_WINDOW_LABEL: &str = "chat-hub";
pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
open_hub_window_with_state(app, true) // Por padrao abre minimizada
}
fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> {
// Verificar se ja existe
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = window.set_ignore_cursor_events(false);
window.show().map_err(|e| e.to_string())?;
let _ = window.unminimize();
if !start_minimized {
window.set_focus().map_err(|e| e.to_string())?;
}
return Ok(());
}
// Dimensoes baseadas no estado inicial
let (width, height) = if start_minimized {
(200.0, 52.0) // Tamanho minimizado (chip)
} else {
(400.0, 520.0) // Tamanho expandido (igual ao web)
};
// Posicionar no canto inferior direito
let (x, y) = resolve_chat_window_position(app, None, width, height);
// URL para modo hub
let url_path = "index.html?view=chat&hub=true";
WebviewWindowBuilder::new(
app,
HUB_WINDOW_LABEL,
WebviewUrl::App(url_path.into()),
)
.title("Chats de Suporte")
.inner_size(width, height)
.min_inner_size(200.0, 52.0)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(false)
.resizable(false) // Desabilitar redimensionamento manual
// Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
.always_on_top(true)
.skip_taskbar(true)
.focused(!start_minimized)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = hub.set_ignore_cursor_events(false);
if !start_minimized {
let _ = hub.set_focus();
}
}
// REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar
// "resize em cima do resize" no timing errado do WebView2
// let _ = set_hub_minimized(app, start_minimized);
crate::log_info!("Hub window aberta (minimizada={})", start_minimized);
Ok(())
}
pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
window.close().map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> {
let _guard = WINDOW_OP_LOCK.lock();
let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?;
let (width, height) = if minimized {
(200.0, 52.0) // Chip minimizado
} else {
(400.0, 520.0) // Lista expandida (igual ao web)
};
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// IGUAL AO CHAT: primeiro size, depois position (ordem importa para hit-test no Windows)
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
// Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat).
if !minimized {
let _ = window.set_focus();
}
crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y);
Ok(())
}

View file

@ -2,8 +2,6 @@ mod agent;
mod chat;
#[cfg(target_os = "windows")]
mod rustdesk;
#[cfg(target_os = "windows")]
mod service_client;
mod usb_control;
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
@ -70,21 +68,21 @@ pub fn log_agent(level: &str, message: &str) {
#[macro_export]
macro_rules! log_info {
($($arg:tt)*) => {
$crate::log_agent("INFO", format!($($arg)*).as_str())
$crate::log_agent("INFO", &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_error {
($($arg:tt)*) => {
$crate::log_agent("ERROR", format!($($arg)*).as_str())
$crate::log_agent("ERROR", &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_warn {
($($arg:tt)*) => {
$crate::log_agent("WARN", format!($($arg)*).as_str())
$crate::log_agent("WARN", &format!($($arg)*))
};
}
@ -191,32 +189,6 @@ fn run_rustdesk_ensure(
password: Option<String>,
machine_id: Option<String>,
) -> Result<RustdeskProvisioningResult, String> {
// Tenta usar o servico primeiro (sem UAC)
if service_client::is_service_available() {
log_info!("Usando Raven Service para provisionar RustDesk");
match service_client::provision_rustdesk(
config_string.as_deref(),
password.as_deref(),
machine_id.as_deref(),
) {
Ok(result) => {
return Ok(RustdeskProvisioningResult {
id: result.id,
password: result.password,
installed_version: result.installed_version,
updated: result.updated,
last_provisioned_at: result.last_provisioned_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para RustDesk: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)");
rustdesk::ensure_rustdesk(
config_string.as_deref(),
password.as_deref(),
@ -236,50 +208,14 @@ fn run_rustdesk_ensure(
#[tauri::command]
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
// Valida a politica primeiro
let _policy_enum = UsbPolicy::from_str(&policy)
let policy_enum = UsbPolicy::from_str(&policy)
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
// Tenta usar o servico primeiro (sem UAC)
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
log_info!("Usando Raven Service para aplicar politica USB: {}", policy);
match service_client::apply_usb_policy(&policy) {
Ok(result) => {
return Ok(UsbPolicyResult {
success: result.success,
policy: result.policy,
error: result.error,
applied_at: result.applied_at,
});
}
Err(e) => {
log_warn!("Falha ao usar servico para USB policy: {e}");
// Continua para fallback
}
}
}
// Fallback: chamada direta (pode pedir UAC)
log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)");
usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string())
usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_usb_policy() -> Result<String, String> {
// Tenta usar o servico primeiro
#[cfg(target_os = "windows")]
if service_client::is_service_available() {
match service_client::get_usb_policy() {
Ok(policy) => return Ok(policy),
Err(e) => {
log_warn!("Falha ao obter USB policy via servico: {e}");
// Continua para fallback
}
}
}
// Fallback: leitura direta (nao precisa elevacao para ler)
usb_control::get_current_policy()
.map(|p| p.as_str().to_string())
.map_err(|e| e.to_string())
@ -410,17 +346,8 @@ async fn upload_chat_file(
}
#[tauri::command]
async fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref);
let app_handle = app.clone();
let ticket_id_for_task = ticket_id.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
chat::open_chat_window(&app_handle, &ticket_id_for_task, ticket_ref)
})
.await
.map_err(|err| format!("Falha ao abrir chat (join): {err}"))?;
log_info!("[CMD] open_chat_window result: {:?}", result);
result
fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
chat::open_chat_window(&app, &ticket_id, ticket_ref)
}
#[tauri::command]
@ -438,26 +365,6 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
chat::set_chat_minimized(&app, &ticket_id, minimized)
}
#[tauri::command]
async fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> {
let app_handle = app.clone();
tauri::async_runtime::spawn_blocking(move || {
chat::open_hub_window(&app_handle)
})
.await
.map_err(|err| format!("Falha ao abrir hub (join): {err}"))?
}
#[tauri::command]
fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> {
chat::close_hub_window(&app)
}
#[tauri::command]
fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> {
chat::set_hub_minimized(&app, minimized)
}
// ============================================================================
// Handler de Deep Link (raven://)
// ============================================================================
@ -545,14 +452,6 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
// Quando uma segunda instância tenta iniciar, foca a janela existente
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
@ -582,7 +481,7 @@ pub fn run() {
{
let start_in_background = std::env::args().any(|arg| arg == "--background");
setup_raven_autostart();
setup_tray(app.handle())?;
setup_tray(&app.handle())?;
if start_in_background {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
@ -627,11 +526,7 @@ pub fn run() {
open_chat_window,
close_chat_window,
minimize_chat_window,
set_chat_minimized,
// Hub commands
open_hub_window,
close_hub_window,
set_hub_minimized
set_chat_minimized
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@ -713,13 +608,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
// Abrir janela de chat se houver sessao ativa
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
let sessions = chat_runtime.get_sessions();
if sessions.len() > 1 {
// Multiplas sessoes - abrir hub
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
log_error!("Falha ao abrir hub de chat: {e}");
}
} else if let Some(session) = sessions.first() {
// Uma sessao - abrir diretamente
if let Some(session) = sessions.first() {
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
log_error!("Falha ao abrir janela de chat: {e}");
}

View file

@ -1,3 +1,5 @@
#![cfg(target_os = "windows")]
use crate::RustdeskProvisioningResult;
use chrono::{Local, Utc};
use once_cell::sync::Lazy;
@ -28,9 +30,7 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\
const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config";
const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados";
const MACHINE_STORE_FILENAME: &str = "machine-agent.json";
#[allow(dead_code)]
const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag";
#[allow(dead_code)]
const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt";
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
@ -85,11 +85,11 @@ fn define_custom_id_from_machine(exe_path: &Path, machine_id: Option<&str>) -> O
}) {
match set_custom_id(exe_path, value) {
Ok(custom) => {
log_event(format!("ID determinístico definido: {custom}"));
log_event(&format!("ID determinístico definido: {custom}"));
Some(custom)
}
Err(error) => {
log_event(format!("Falha ao definir ID determinístico: {error}"));
log_event(&format!("Falha ao definir ID determinístico: {error}"));
None
}
}
@ -107,7 +107,7 @@ pub fn ensure_rustdesk(
log_event("Iniciando preparo do RustDesk");
if let Err(error) = ensure_service_profiles_writable_preflight() {
log_event(format!(
log_event(&format!(
"Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha."
));
}
@ -116,7 +116,7 @@ pub fn ensure_rustdesk(
// Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece
let preserved_remote_id = read_remote_id_from_profiles();
if let Some(ref id) = preserved_remote_id {
log_event(format!("ID existente preservado antes da limpeza: {}", id));
log_event(&format!("ID existente preservado antes da limpeza: {}", id));
}
let exe_path = detect_executable_path();
@ -129,7 +129,7 @@ pub fn ensure_rustdesk(
match stop_rustdesk_processes() {
Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"),
Err(error) => log_event(format!(
Err(error) => log_event(&format!(
"Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})"
)),
}
@ -139,7 +139,7 @@ pub fn ensure_rustdesk(
if freshly_installed {
match purge_existing_rustdesk_profiles() {
Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"),
Err(error) => log_event(format!(
Err(error) => log_event(&format!(
"Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})"
)),
}
@ -152,19 +152,19 @@ pub fn ensure_rustdesk(
if trimmed.is_empty() { None } else { Some(trimmed) }
}) {
if let Err(error) = run_with_args(&exe_path, &["--config", value]) {
log_event(format!("Falha ao aplicar configuração inline: {error}"));
log_event(&format!("Falha ao aplicar configuração inline: {error}"));
} else {
log_event("Configuração aplicada via --config");
}
} else {
let config_path = write_config_files()?;
log_event(format!(
log_event(&format!(
"Arquivo de configuração atualizado em {}",
config_path.display()
));
if let Err(error) = apply_config(&exe_path, &config_path) {
log_event(format!("Falha ao aplicar configuração via CLI: {error}"));
log_event(&format!("Falha ao aplicar configuração via CLI: {error}"));
} else {
log_event("Configuração aplicada via CLI");
}
@ -176,7 +176,7 @@ pub fn ensure_rustdesk(
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
if let Err(error) = set_password(&exe_path, &password) {
log_event(format!("Falha ao definir senha padrão: {error}"));
log_event(&format!("Falha ao definir senha padrão: {error}"));
} else {
log_event("Senha padrão definida com sucesso");
log_event("Aplicando senha nos perfis do RustDesk");
@ -185,21 +185,21 @@ pub fn ensure_rustdesk(
log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk");
log_password_replication(&password);
}
Err(error) => log_event(format!("Falha ao persistir senha nos perfis: {error}")),
Err(error) => log_event(&format!("Falha ao persistir senha nos perfis: {error}")),
}
match propagate_password_profile() {
Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"),
Err(error) => log_event(format!("Falha ao copiar perfil de senha: {error}")),
Err(error) => log_event(&format!("Falha ao copiar perfil de senha: {error}")),
}
match replicate_password_artifacts() {
Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"),
Err(error) => log_event(format!("Falha ao replicar artefatos de senha: {error}")),
Err(error) => log_event(&format!("Falha ao replicar artefatos de senha: {error}")),
}
if let Err(error) = enforce_security_flags() {
log_event(format!("Falha ao reforçar configuração de senha permanente: {error}"));
log_event(&format!("Falha ao reforçar configuração de senha permanente: {error}"));
}
}
@ -207,7 +207,7 @@ pub fn ensure_rustdesk(
// Isso garante que reinstalar o Raven nao muda o ID do RustDesk
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
if !freshly_installed {
log_event(format!("Reutilizando ID existente do RustDesk: {}", existing_id));
log_event(&format!("Reutilizando ID existente do RustDesk: {}", existing_id));
Some(existing_id.clone())
} else {
// Instalacao fresca - define novo ID baseado no machine_id
@ -219,7 +219,7 @@ pub fn ensure_rustdesk(
};
if let Err(error) = ensure_service_running(&exe_path) {
log_event(format!("Falha ao reiniciar serviço do RustDesk: {error}"));
log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}"));
} else {
log_event("Serviço RustDesk reiniciado/run ativo");
}
@ -227,10 +227,10 @@ pub fn ensure_rustdesk(
let reported_id = match query_id_with_retries(&exe_path, 5) {
Ok(value) => value,
Err(error) => {
log_event(format!("Falha ao obter ID após múltiplas tentativas: {error}"));
log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}"));
match read_remote_id_from_profiles().or_else(|| custom_id.clone()) {
Some(value) => {
log_event(format!("ID obtido via arquivos de perfil: {value}"));
log_event(&format!("ID obtido via arquivos de perfil: {value}"));
value
}
None => return Err(error),
@ -242,7 +242,7 @@ pub fn ensure_rustdesk(
if let Some(expected) = custom_id.as_ref() {
if expected != &reported_id {
log_event(format!(
log_event(&format!(
"ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico"
));
@ -252,25 +252,25 @@ pub fn ensure_rustdesk(
Ok(_) => match query_id_with_retries(&exe_path, 3) {
Ok(rechecked) => {
if &rechecked == expected {
log_event(format!("ID determinístico aplicado com sucesso: {rechecked}"));
log_event(&format!("ID determinístico aplicado com sucesso: {rechecked}"));
final_id = rechecked;
enforced = true;
} else {
log_event(format!(
log_event(&format!(
"ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado"
));
final_id = rechecked;
}
}
Err(error) => {
log_event(format!(
log_event(&format!(
"Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})"
));
final_id = reported_id.clone();
}
},
Err(error) => {
log_event(format!(
log_event(&format!(
"Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})"
));
final_id = reported_id.clone();
@ -308,7 +308,7 @@ pub fn ensure_rustdesk(
"lastError": serde_json::Value::Null
});
if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) {
log_event(format!("Aviso: falha ao salvar dados do RustDesk no store: {error}"));
log_event(&format!("Aviso: falha ao salvar dados do RustDesk no store: {error}"));
} else {
log_event("Dados do RustDesk salvos no machine-agent.json");
}
@ -316,7 +316,7 @@ pub fn ensure_rustdesk(
// Sincroniza com o backend imediatamente apos provisionar
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
if let Err(error) = sync_remote_access_with_backend(&result) {
log_event(format!("Aviso: falha ao sincronizar com backend: {error}"));
log_event(&format!("Aviso: falha ao sincronizar com backend: {error}"));
} else {
log_event("Acesso remoto sincronizado com backend");
// Atualiza lastSyncedAt no store
@ -330,13 +330,13 @@ pub fn ensure_rustdesk(
"lastError": serde_json::Value::Null
});
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
log_event(format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
} else {
log_event("lastSyncedAt atualizado com sucesso");
}
}
log_event(format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
Ok(result)
}
@ -403,7 +403,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
let config_contents = build_config_contents();
let main_path = program_data_config_dir().join("RustDesk2.toml");
write_file(&main_path, &config_contents)?;
log_event(format!(
log_event(&format!(
"Config principal gravada em {}",
main_path.display()
));
@ -412,7 +412,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
for service_dir in service_profile_dirs() {
let service_profile = service_dir.join("RustDesk2.toml");
if let Err(error) = write_file(&service_profile, &config_contents) {
log_event(format!(
log_event(&format!(
"Falha ao gravar config no perfil do serviço ({}): {error}",
service_profile.display()
));
@ -421,7 +421,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") {
if let Err(error) = write_file(&appdata_path, &config_contents) {
log_event(format!(
log_event(&format!(
"Falha ao atualizar config no AppData do usuário: {error}"
));
}
@ -516,7 +516,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
ensure_service_installed(exe_path)?;
if let Err(error) = configure_service_startup() {
log_event(format!(
log_event(&format!(
"Aviso: não foi possível reforçar autostart/recuperação do serviço RustDesk: {error}"
));
}
@ -553,7 +553,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
let _ = run_with_args(exe_path, &["--install-service"]);
let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]);
if let Err(error) = start_sequence() {
log_event(format!(
log_event(&format!(
"Falha ao subir o serviço RustDesk mesmo após reinstalação: {error}"
));
}
@ -631,8 +631,8 @@ fn remove_rustdesk_autorun_artifacts() {
for path in startup_paths {
if path.exists() {
match fs::remove_file(&path) {
Ok(_) => log_event(format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
Err(error) => log_event(format!(
Ok(_) => log_event(&format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
Err(error) => log_event(&format!(
"Falha ao remover atalho de inicialização do RustDesk ({}): {}",
path.display(),
error
@ -650,7 +650,7 @@ fn remove_rustdesk_autorun_artifacts() {
.status();
if let Ok(code) = status {
if code.success() {
log_event(format!("Entrada de auto-run RustDesk removida de {}", reg_path));
log_event(&format!("Entrada de auto-run RustDesk removida de {}", reg_path));
}
}
}
@ -658,7 +658,7 @@ fn remove_rustdesk_autorun_artifacts() {
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
if let Err(error) = try_stop_service() {
log_event(format!(
log_event(&format!(
"Não foi possível parar o serviço RustDesk antes da sincronização: {error}"
));
}
@ -774,12 +774,12 @@ fn ensure_remote_id_files(id: &str) {
for dir in remote_id_directories() {
let path = dir.join("RustDesk_local.toml");
match write_remote_id_value(&path, id) {
Ok(_) => log_event(format!(
Ok(_) => log_event(&format!(
"remote_id atualizado para {} em {}",
id,
path.display()
)),
Err(error) => log_event(format!(
Err(error) => log_event(&format!(
"Falha ao atualizar remote_id em {}: {error}",
path.display()
)),
@ -821,7 +821,7 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
if let Err(error) = write_toml_kv(&password_path, "password", secret) {
errors.push(format!("{} -> {}", password_path.display(), error));
} else {
log_event(format!(
log_event(&format!(
"Senha escrita via fallback em {}",
password_path.display()
));
@ -829,12 +829,12 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
let local_path = dir.join("RustDesk_local.toml");
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
log_event(format!(
log_event(&format!(
"Falha ao ajustar verification-method em {}: {error}",
local_path.display()
));
} else {
log_event(format!(
log_event(&format!(
"verification-method atualizado para {} em {}",
SECURITY_VERIFICATION_VALUE,
local_path.display()
@ -843,19 +843,19 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
let rustdesk2_path = dir.join("RustDesk2.toml");
if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) {
log_event(format!(
log_event(&format!(
"Falha ao ajustar flags no RustDesk2.toml em {}: {error}",
rustdesk2_path.display()
));
}
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
log_event(format!(
log_event(&format!(
"Falha ao ajustar approve-mode em {}: {error}",
local_path.display()
));
} else {
log_event(format!(
log_event(&format!(
"approve-mode atualizado para {} em {}",
SECURITY_APPROVE_MODE_VALUE,
local_path.display()
@ -877,7 +877,7 @@ fn enforce_security_flags() -> Result<(), String> {
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
errors.push(format!("{} -> {}", local_path.display(), error));
} else {
log_event(format!(
log_event(&format!(
"verification-method atualizado para {} em {}",
SECURITY_VERIFICATION_VALUE,
local_path.display()
@ -887,7 +887,7 @@ fn enforce_security_flags() -> Result<(), String> {
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
errors.push(format!("{} -> {}", local_path.display(), error));
} else {
log_event(format!(
log_event(&format!(
"approve-mode atualizado para {} em {}",
SECURITY_APPROVE_MODE_VALUE,
local_path.display()
@ -921,7 +921,7 @@ fn propagate_password_profile() -> io::Result<bool> {
if !src_path.exists() {
continue;
}
log_event(format!(
log_event(&format!(
"Copiando {} para ProgramData/serviços",
src_path.display()
));
@ -929,7 +929,7 @@ fn propagate_password_profile() -> io::Result<bool> {
for dest_root in propagation_destinations() {
let target_path = dest_root.join(filename);
copy_overwrite(&src_path, &target_path)?;
log_event(format!(
log_event(&format!(
"{} propagado para {}",
filename,
target_path.display()
@ -969,7 +969,7 @@ fn replicate_password_artifacts() -> io::Result<()> {
let target_path = dest.join(name);
copy_overwrite(&source_path, &target_path)?;
log_event(format!(
log_event(&format!(
"Artefato de senha {name} replicado para {}",
target_path.display()
));
@ -981,11 +981,13 @@ fn replicate_password_artifacts() -> io::Result<()> {
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
let mut errors = Vec::new();
let mut cleaned_any = false;
for dir in remote_id_directories() {
match purge_config_dir(&dir) {
Ok(true) => {
log_event(format!(
cleaned_any = true;
log_event(&format!(
"Perfis antigos removidos em {}",
dir.display()
));
@ -995,7 +997,9 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> {
}
}
if errors.is_empty() {
if cleaned_any {
Ok(())
} else if errors.is_empty() {
Ok(())
} else {
Err(errors.join(" | "))
@ -1026,7 +1030,6 @@ fn purge_config_dir(dir: &Path) -> Result<bool, io::Error> {
Ok(removed)
}
#[allow(dead_code)]
fn run_powershell_elevated(script: &str) -> Result<(), String> {
let temp_dir = env::temp_dir();
let payload = temp_dir.join("raven_payload.ps1");
@ -1074,7 +1077,6 @@ exit $process.ExitCode
Err(format!("elevated ps exit {:?}", status.code()))
}
#[allow(dead_code)]
fn fix_profile_acl(target: &Path) -> Result<(), String> {
let target_str = target.display().to_string();
let transcript = env::temp_dir().join("raven_acl_ps.log");
@ -1109,7 +1111,7 @@ try {{
let result = run_powershell_elevated(&script);
if result.is_err() {
if let Ok(content) = fs::read_to_string(&transcript) {
log_event(format!(
log_event(&format!(
"ACL transcript para {}:\n{}",
target.display(), content
));
@ -1120,9 +1122,6 @@ try {{
}
fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
// Verificamos se os diretorios de perfil sao graváveis
// Se nao forem, apenas logamos aviso - o Raven Service deve lidar com isso
// Nao usamos elevacao para evitar UAC adicional
let mut blocked_dirs = Vec::new();
for dir in service_profile_dirs() {
if !can_write_dir(&dir) {
@ -1134,46 +1133,53 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
return Ok(());
}
// Apenas logamos aviso - o serviço RavenService deve lidar com permissões
log_event(format!(
"Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.",
blocked_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()
));
if has_acl_unlock_flag() {
log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL");
} else {
log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)");
}
// Retornamos Ok para não bloquear o fluxo
// O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios
Ok(())
let mut last_error: Option<String> = None;
for dir in blocked_dirs.iter() {
log_event(&format!(
"Tentando corrigir ACL via UAC (preflight) em {}...",
dir.display()
));
if let Err(error) = fix_profile_acl(dir) {
last_error = Some(error);
continue;
}
if can_write_dir(dir) {
log_event(&format!(
"ACL ajustada com sucesso em {}",
dir.display()
));
} else {
last_error = Some(format!(
"continua sem permissão para {} mesmo após preflight",
dir.display()
));
}
}
if blocked_dirs.iter().all(|dir| can_write_dir(dir)) {
mark_acl_unlock_flag();
Ok(())
} else {
Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into()))
}
}
fn stop_service_elevated() -> Result<(), String> {
// Tentamos parar o serviço RustDesk sem elevação
// Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso
// Não usamos elevação para evitar UAC adicional
let output = Command::new("sc")
.args(["stop", "RustDesk"])
.output();
match output {
Ok(result) => {
if result.status.success() {
// Aguarda um pouco para o serviço parar
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
log_event(format!(
"Aviso: não foi possível parar o serviço RustDesk sem elevação: {}",
stderr.trim()
));
// Retornamos Ok para não bloquear - o serviço pode estar já parado
Ok(())
}
}
Err(e) => {
log_event(format!("Aviso: falha ao executar sc stop RustDesk: {e}"));
Ok(())
}
}
let script = r#"
$ErrorActionPreference='Stop'
$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
if ($service -and $service.Status -ne 'Stopped') {
Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop
$service.WaitForStatus('Stopped','00:00:10')
}
"#;
run_powershell_elevated(script)
}
fn can_write_dir(dir: &Path) -> bool {
@ -1333,21 +1339,21 @@ fn log_password_replication(secret: &str) {
fn log_password_match(path: &Path, secret: &str) {
match read_password_from_file(path) {
Some(value) if value == secret => {
log_event(format!(
log_event(&format!(
"Senha confirmada em {} ({})",
path.display(),
mask_secret(&value)
));
}
Some(value) => {
log_event(format!(
log_event(&format!(
"Aviso: senha divergente ({}) em {}",
mask_secret(&value),
path.display()
));
}
None => {
log_event(format!(
log_event(&format!(
"Aviso: chave 'password' não encontrada em {}",
path.display()
));
@ -1463,24 +1469,21 @@ fn write_machine_store_object(map: JsonMap<String, JsonValue>) -> Result<(), Str
}
fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> {
let mut map = read_machine_store_object().unwrap_or_default();
let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new);
map.insert(key.to_string(), value);
write_machine_store_object(map)
}
#[allow(dead_code)]
fn machine_store_key_exists(key: &str) -> bool {
read_machine_store_object()
.map(|map| map.contains_key(key))
.unwrap_or(false)
}
#[allow(dead_code)]
fn acl_flag_file_path() -> Option<PathBuf> {
raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME))
}
#[allow(dead_code)]
fn has_acl_unlock_flag() -> bool {
if let Some(flag) = acl_flag_file_path() {
if flag.exists() {
@ -1490,7 +1493,6 @@ fn has_acl_unlock_flag() -> bool {
machine_store_key_exists(RUSTDESK_ACL_STORE_KEY)
}
#[allow(dead_code)]
fn mark_acl_unlock_flag() {
let timestamp = Utc::now().timestamp_millis();
if let Some(flag_path) = acl_flag_file_path() {
@ -1498,7 +1500,7 @@ fn mark_acl_unlock_flag() {
let _ = fs::create_dir_all(parent);
}
if let Err(error) = fs::write(&flag_path, timestamp.to_string()) {
log_event(format!(
log_event(&format!(
"Falha ao gravar flag de ACL em {}: {error}",
flag_path.display()
));
@ -1506,7 +1508,7 @@ fn mark_acl_unlock_flag() {
}
if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) {
log_event(format!(
log_event(&format!(
"Falha ao registrar flag de ACL no machine-agent: {error}"
));
}
@ -1545,7 +1547,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
.and_then(|v| v.as_str())
.unwrap_or("https://tickets.esdrasrenan.com.br");
log_event(format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
// Monta payload conforme schema esperado pelo backend
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
@ -1573,13 +1575,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
.send()?;
if response.status().is_success() {
log_event(format!("Sync com backend OK: status {}", response.status()));
log_event(&format!("Sync com backend OK: status {}", response.status()));
Ok(())
} else {
let status = response.status();
let body = response.text().unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
log_event(format!("Sync com backend falhou: {} - {}", status, body_preview));
log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview));
Err(RustdeskError::CommandFailed {
command: "sync_remote_access".to_string(),
status: Some(status.as_u16() as i32)

View file

@ -1,244 +0,0 @@
//! Cliente IPC para comunicacao com o Raven Service
//!
//! Este modulo permite que o app Tauri se comunique com o Raven Service
//! via Named Pipes para executar operacoes privilegiadas.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use thiserror::Error;
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
#[derive(Debug, Error)]
pub enum ServiceClientError {
#[error("Servico nao disponivel: {0}")]
ServiceUnavailable(String),
#[error("Erro de comunicacao: {0}")]
CommunicationError(String),
#[error("Erro de serializacao: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Erro do servico: {message} (code: {code})")]
ServiceError { code: i32, message: String },
#[error("Timeout aguardando resposta")]
Timeout,
}
#[derive(Debug, Serialize)]
struct Request {
id: String,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct Response {
id: String,
result: Option<serde_json::Value>,
error: Option<ErrorResponse>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
code: i32,
message: String,
}
// =============================================================================
// Tipos de Resultado
// =============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbPolicyResult {
pub success: bool,
pub policy: String,
pub error: Option<String>,
pub applied_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskStatus {
pub installed: bool,
pub running: bool,
pub id: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckResult {
pub status: String,
pub service: String,
pub version: String,
pub timestamp: i64,
}
// =============================================================================
// Cliente
// =============================================================================
/// Verifica se o servico esta disponivel
pub fn is_service_available() -> bool {
health_check().is_ok()
}
/// Verifica saude do servico
pub fn health_check() -> Result<HealthCheckResult, ServiceClientError> {
let response = call_service("health_check", serde_json::json!({}))?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Aplica politica de USB
pub fn apply_usb_policy(policy: &str) -> Result<UsbPolicyResult, ServiceClientError> {
let response = call_service(
"apply_usb_policy",
serde_json::json!({ "policy": policy }),
)?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Obtem politica de USB atual
pub fn get_usb_policy() -> Result<String, ServiceClientError> {
let response = call_service("get_usb_policy", serde_json::json!({}))?;
response
.get("policy")
.and_then(|p| p.as_str())
.map(String::from)
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta invalida".into()))
}
/// Provisiona RustDesk
pub fn provision_rustdesk(
config: Option<&str>,
password: Option<&str>,
machine_id: Option<&str>,
) -> Result<RustdeskResult, ServiceClientError> {
let params = serde_json::json!({
"config": config,
"password": password,
"machineId": machine_id,
});
let response = call_service("provision_rustdesk", params)?;
serde_json::from_value(response).map_err(|e| e.into())
}
/// Obtem status do RustDesk
pub fn get_rustdesk_status() -> Result<RustdeskStatus, ServiceClientError> {
let response = call_service("get_rustdesk_status", serde_json::json!({}))?;
serde_json::from_value(response).map_err(|e| e.into())
}
// =============================================================================
// Comunicacao IPC
// =============================================================================
fn call_service(
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, ServiceClientError> {
// Gera ID unico para a requisicao
let id = uuid::Uuid::new_v4().to_string();
let request = Request {
id: id.clone(),
method: method.to_string(),
params,
};
// Serializa requisicao
let request_json = serde_json::to_string(&request)?;
// Conecta ao pipe
let mut pipe = connect_to_pipe()?;
// Envia requisicao
writeln!(pipe, "{}", request_json).map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao enviar requisicao: {}", e))
})?;
pipe.flush().map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao flush: {}", e))
})?;
// Le resposta
let mut reader = BufReader::new(pipe);
let mut response_line = String::new();
reader.read_line(&mut response_line).map_err(|e| {
ServiceClientError::CommunicationError(format!("Erro ao ler resposta: {}", e))
})?;
// Parse da resposta
let response: Response = serde_json::from_str(&response_line)?;
// Verifica se o ID bate
if response.id != id {
return Err(ServiceClientError::CommunicationError(
"ID de resposta nao corresponde".into(),
));
}
// Verifica erro
if let Some(error) = response.error {
return Err(ServiceClientError::ServiceError {
code: error.code,
message: error.message,
});
}
// Retorna resultado
response
.result
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta sem resultado".into()))
}
#[cfg(target_os = "windows")]
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
// Tenta conectar ao pipe com retry
let mut attempts = 0;
let max_attempts = 3;
loop {
match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(PIPE_NAME)
{
Ok(file) => return Ok(file),
Err(e) => {
attempts += 1;
if attempts >= max_attempts {
return Err(ServiceClientError::ServiceUnavailable(format!(
"Nao foi possivel conectar ao servico apos {} tentativas: {}",
max_attempts, e
)));
}
std::thread::sleep(Duration::from_millis(500));
}
}
}
}
#[cfg(not(target_os = "windows"))]
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
Err(ServiceClientError::ServiceUnavailable(
"Named Pipes so estao disponiveis no Windows".into(),
))
}

View file

@ -93,10 +93,22 @@ mod windows_impl {
applied_at: Some(now),
}),
Err(err) => {
// Se faltou permissão, retorna erro - o serviço deve ser usado
// Não fazemos elevação aqui para evitar UAC adicional
// Tenta elevação se faltou permissão
if is_permission_error(&err) {
return Err(UsbControlError::PermissionDenied);
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
return Err(elevated_err);
}
// Revalida a policy após elevação
let current = get_current_policy()?;
if current != policy {
return Err(UsbControlError::PermissionDenied);
}
return Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
});
}
Err(err)
}
@ -207,8 +219,10 @@ mod windows_impl {
key.set_value("WriteProtect", &1u32)
.map_err(map_winreg_error)?;
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
} else {
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
}
}
Ok(())
@ -255,7 +269,6 @@ mod windows_impl {
}
}
#[allow(dead_code)]
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
// Cria script temporário para aplicar as chaves via PowerShell elevado
let temp_dir = std::env::temp_dir();
@ -308,7 +321,7 @@ try {{
policy = policy_str
);
fs::write(&script_path, script).map_err(UsbControlError::Io)?;
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?;
// Start-Process com RunAs para acionar UAC
let arg = format!(
@ -320,7 +333,7 @@ try {{
.arg("-Command")
.arg(arg)
.status()
.map_err(UsbControlError::Io)?;
.map_err(|e| UsbControlError::Io(e))?;
if !status.success() {
return Err(UsbControlError::PermissionDenied);
@ -349,7 +362,7 @@ try {{
.args(["/target:computer", "/force"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(UsbControlError::Io)?;
.map_err(|e| UsbControlError::Io(e))?;
if !output.status.success() {
// Nao e critico se falhar, apenas log

View file

@ -50,9 +50,6 @@
"icons/icon.png",
"icons/Raven.png"
],
"resources": {
"../service/target/release/raven-service.exe": "raven-service.exe"
},
"windows": {
"webviewInstallMode": {
"type": "skip"

View file

@ -1,256 +0,0 @@
/**
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
*
* Arquitetura:
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
* - Tauri usado apenas para gerenciamento de janelas
*/
import { useEffect, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
/**
* Hub Widget - Lista todas as sessoes de chat ativas
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
*/
export function ChatHubWidget() {
// Inicializa baseado na altura real da janela (< 100px = minimizado)
const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100)
// Convex subscription reativa
const { sessions = [], isLoading, hasToken } = useMachineSessions()
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
const mountTime = Date.now()
const STABILIZATION_DELAY = 500
const handler = () => {
if (Date.now() - mountTime < STABILIZATION_DELAY) {
return
}
const h = window.innerHeight
setIsMinimized(h < 100)
}
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
try {
// Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS)
await invoke("open_chat_window", { ticketId, ticketRef })
await invoke("close_hub_window")
} catch (err) {
console.error("open_chat_window FAILED:", err)
}
}
const handleMinimize = async () => {
setIsMinimized(true)
try {
await invoke("set_hub_minimized", { minimized: true })
} catch (err) {
console.error("Erro ao minimizar hub:", err)
}
}
const handleExpand = async () => {
try {
await invoke("set_hub_minimized", { minimized: false })
setTimeout(() => setIsMinimized(false), 100)
} catch (err) {
console.error("set_hub_minimized FAILED:", err)
setIsMinimized(false)
}
}
const handleClose = () => {
invoke("close_hub_window").catch((err) => {
console.error("Erro ao fechar janela do hub:", err)
})
}
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
// Sem token
if (!hasToken) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Token nao configurado</span>
</div>
</div>
)
}
// Loading
if (isLoading) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Carregando...</span>
</div>
</div>
)
}
// Sem sessoes ativas
if (sessions.length === 0) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<MessageCircle className="size-4" />
<span className="text-sm font-medium">Sem chats</span>
</div>
</div>
)
}
// Minimizado
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
<button
onClick={(e) => {
e.stopPropagation()
handleExpand()
}}
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
{sessions.length} chat{sessions.length !== 1 ? "s" : ""}
</span>
<span className="size-2 rounded-full bg-emerald-400" />
<ChevronUp className="size-4" />
{totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread > 9 ? "9+" : totalUnread}
</span>
)}
</button>
</div>
)
}
// Expandido
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Header */}
<div
data-tauri-drag-region
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
aria-label="Minimizar lista de chats"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={handleClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
aria-label="Fechar lista de chats"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{sessions.map((session) => (
<SessionItem
key={session.sessionId}
session={session}
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
/>
))}
</div>
</div>
</div>
)
}
function SessionItem({
session,
onClick,
}: {
session: MachineSession
onClick: () => void
}) {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
onClick()
}
return (
<button
onClick={handleClick}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
>
{/* Avatar */}
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
{/* Indicador online */}
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium text-slate-900">
Ticket #{session.ticketRef}
</p>
<span className="shrink-0 text-xs text-slate-400">
{formatRelativeTime(session.lastActivityAt)}
</span>
</div>
<p className="truncate text-xs text-slate-500">
{session.agentName}
</p>
</div>
{/* Badge nao lidas */}
{session.unreadCount > 0 && (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
)}
</button>
)
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return "agora"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}

File diff suppressed because it is too large Load diff

View file

@ -1,146 +0,0 @@
/**
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
*
* Este provider inicializa o ConvexReactClient usando o token da maquina
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
*
* Arquitetura:
* - Carrega o token do Tauri Store na montagem
* - Inicializa o ConvexReactClient com a URL do Convex
* - Disponibiliza o cliente para componentes filhos via Context
* - Reconecta automaticamente quando o token muda
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { ConvexReactClient } from "convex/react"
import { getMachineStoreConfig } from "./machineStore"
// URL do Convex - em producao, usa o dominio personalizado
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineAuthState = {
token: string | null
apiBaseUrl: string | null
isLoading: boolean
error: string | null
}
type ConvexMachineContextValue = {
client: ConvexReactClient | null
machineToken: string | null
apiBaseUrl: string | null
isReady: boolean
error: string | null
reload: () => Promise<void>
}
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
export function useConvexMachine() {
const ctx = useContext(ConvexMachineContext)
if (!ctx) {
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
}
return ctx
}
export function useMachineToken() {
const { machineToken } = useConvexMachine()
return machineToken
}
interface ConvexMachineProviderProps {
children: ReactNode
}
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
const [authState, setAuthState] = useState<MachineAuthState>({
token: null,
apiBaseUrl: null,
isLoading: true,
error: null,
})
const [client, setClient] = useState<ConvexReactClient | null>(null)
// Funcao para carregar configuracao do Tauri Store
const loadConfig = async () => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
try {
const config = await getMachineStoreConfig()
if (!config.token) {
setAuthState({
token: null,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: "Token da maquina nao encontrado",
})
return
}
setAuthState({
token: config.token,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: null,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setAuthState({
token: null,
apiBaseUrl: null,
isLoading: false,
error: message || "Erro ao carregar configuracao",
})
}
}
// Carregar configuracao na montagem
useEffect(() => {
loadConfig()
}, [])
// Inicializar/reinicializar cliente Convex quando token muda
useEffect(() => {
if (!authState.token) {
// Limpar cliente se nao tem token
if (client) {
client.close()
setClient(null)
}
return
}
// Criar novo cliente Convex
const newClient = new ConvexReactClient(CONVEX_URL, {
// Desabilitar retry agressivo para evitar loops infinitos
unsavedChangesWarning: false,
})
setClient(newClient)
// Cleanup ao desmontar ou trocar token
return () => {
newClient.close()
}
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
const contextValue: ConvexMachineContextValue = {
client,
machineToken: authState.token,
apiBaseUrl: authState.apiBaseUrl,
isReady: !authState.isLoading && !!client && !!authState.token,
error: authState.error,
reload: loadConfig,
}
return (
<ConvexMachineContext.Provider value={contextValue}>
{children}
</ConvexMachineContext.Provider>
)
}

View file

@ -1,41 +0,0 @@
const AUDIO_MIME_CANDIDATES = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
]
const AUDIO_MIME_EXTENSION_MAP: Record<string, string> = {
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/mp4": "m4a",
"audio/mpeg": "mp3",
"audio/wav": "wav",
}
export function normalizeMimeType(mimeType: string) {
return mimeType.split(";")[0].trim().toLowerCase()
}
export function pickSupportedMimeType(isTypeSupported?: (mimeType: string) => boolean) {
const checker = isTypeSupported ?? (
typeof MediaRecorder === "undefined" ? undefined : MediaRecorder.isTypeSupported.bind(MediaRecorder)
)
if (!checker) return ""
for (const candidate of AUDIO_MIME_CANDIDATES) {
if (checker(candidate)) return candidate
}
return ""
}
export function buildAudioFileName(mimeType: string, now: Date = new Date()) {
const normalized = normalizeMimeType(mimeType)
const ext = AUDIO_MIME_EXTENSION_MAP[normalized] ?? "webm"
const timestamp = now.toISOString().replace(/[:.]/g, "-")
return `audio-${timestamp}.${ext}`
}

View file

@ -1,65 +1,21 @@
import { ConvexProvider } from "convex/react"
import { ChatWidget } from "./ChatWidget"
import { ChatHubWidget } from "./ChatHubWidget"
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
import { Loader2 } from "lucide-react"
function ChatAppContent() {
const { client, isReady, error } = useConvexMachine()
export function ChatApp() {
// Obter ticketId e ticketRef da URL
const params = new URLSearchParams(window.location.search)
const ticketId = params.get("ticketId")
const ticketRef = params.get("ticketRef")
const isHub = params.get("hub") === "true"
// Aguardar cliente Convex estar pronto
if (!isReady || !client) {
if (error) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Erro: {error}</span>
</div>
</div>
)
}
if (!ticketId) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Conectando...</span>
</div>
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p>
</div>
)
}
// Modo hub - lista de todas as sessoes
if (isHub || !ticketId) {
return (
<ConvexProvider client={client}>
<ChatHubWidget />
</ConvexProvider>
)
}
// Modo chat - conversa de um ticket especifico
return (
<ConvexProvider client={client}>
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
</ConvexProvider>
)
}
export function ChatApp() {
return (
<ConvexMachineProvider>
<ChatAppContent />
</ConvexMachineProvider>
)
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
}
export { ChatWidget }
export { ChatHubWidget }
export * from "./types"

View file

@ -1,253 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { buildAudioFileName, pickSupportedMimeType } from "./audio-recorder-utils"
type AudioRecorderPayload = {
file: File
durationSeconds: number
}
type AudioRecorderOptions = {
onAudioReady: (payload: AudioRecorderPayload) => Promise<void>
onError?: (message: string) => void
maxDurationSeconds?: number
maxFileSizeBytes?: number
audioBitsPerSecond?: number
levelBars?: number
}
type AudioRecorderState = {
isRecording: boolean
isProcessing: boolean
durationSeconds: number
levels: number[]
startRecording: () => Promise<void>
stopRecording: () => void
cancelRecording: () => void
}
export function useAudioRecorder(options: AudioRecorderOptions): AudioRecorderState {
const {
onAudioReady,
onError,
maxDurationSeconds = 300,
maxFileSizeBytes = 5 * 1024 * 1024,
audioBitsPerSecond = 64000,
levelBars = 32,
} = options
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [durationSeconds, setDurationSeconds] = useState(0)
const [levels, setLevels] = useState<number[]>(() => Array.from({ length: levelBars }, () => 0))
const durationRef = useRef(0)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const chunksRef = useRef<BlobPart[]>([])
const timerRef = useRef<number | null>(null)
const stopTimeoutRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const cancelRef = useRef(false)
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (stopTimeoutRef.current) {
clearTimeout(stopTimeoutRef.current)
stopTimeoutRef.current = null
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
if (audioContextRef.current) {
void audioContextRef.current.close()
audioContextRef.current = null
}
analyserRef.current = null
recorderRef.current = null
chunksRef.current = []
}, [])
const updateLevels = useCallback(() => {
const analyser = analyserRef.current
if (!analyser) return
const bufferLength = analyser.fftSize
const dataArray = new Uint8Array(bufferLength)
analyser.getByteTimeDomainData(dataArray)
const step = Math.floor(bufferLength / levelBars)
const nextLevels = Array.from({ length: levelBars }, (_, index) => {
let sum = 0
const start = index * step
const end = Math.min(start + step, bufferLength)
for (let i = start; i < end; i += 1) {
sum += Math.abs(dataArray[i] - 128)
}
const avg = sum / Math.max(1, end - start)
return Math.min(1, avg / 128)
})
if (mountedRef.current) {
setLevels(nextLevels)
rafRef.current = requestAnimationFrame(updateLevels)
}
}, [levelBars])
const stopRecording = useCallback(() => {
if (!recorderRef.current || !isRecording) return
setIsRecording(false)
try {
recorderRef.current.stop()
} catch (error) {
console.error("Falha ao parar gravação:", error)
cleanup()
}
}, [cleanup, isRecording])
const cancelRecording = useCallback(() => {
cancelRef.current = true
stopRecording()
}, [stopRecording])
const startRecording = useCallback(async () => {
if (isRecording || isProcessing) return
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
onError?.("Gravação de áudio indisponível neste dispositivo.")
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
analyser.fftSize = 256
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
audioContextRef.current = audioContext
analyserRef.current = analyser
const mimeType = pickSupportedMimeType()
const recorderOptions: MediaRecorderOptions = mimeType
? { mimeType, audioBitsPerSecond }
: { audioBitsPerSecond }
const recorder = new MediaRecorder(stream, recorderOptions)
recorderRef.current = recorder
chunksRef.current = []
cancelRef.current = false
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
recorder.onstop = async () => {
const blobType = recorder.mimeType || mimeType || "audio/webm"
const blob = new Blob(chunksRef.current, { type: blobType })
chunksRef.current = []
cleanup()
if (cancelRef.current) {
if (mountedRef.current) {
setLevels(Array.from({ length: levelBars }, () => 0))
}
return
}
if (blob.size > maxFileSizeBytes) {
onError?.("Áudio excede o limite de 5MB. Tente gravar por menos tempo.")
if (mountedRef.current) {
setLevels(Array.from({ length: levelBars }, () => 0))
}
return
}
const fileName = buildAudioFileName(blobType)
const file = new File([blob], fileName, { type: blobType })
setIsProcessing(true)
try {
await onAudioReady({ file, durationSeconds: durationRef.current })
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao enviar áudio."
onError?.(message)
} finally {
if (mountedRef.current) {
setIsProcessing(false)
setLevels(Array.from({ length: levelBars }, () => 0))
}
}
}
recorder.start()
durationRef.current = 0
setDurationSeconds(0)
setIsRecording(true)
updateLevels()
timerRef.current = window.setInterval(() => {
setDurationSeconds((prev) => {
const next = prev + 1
durationRef.current = next
if (next >= maxDurationSeconds) {
stopRecording()
return next
}
return next
})
}, 1000)
stopTimeoutRef.current = window.setTimeout(() => {
stopRecording()
}, maxDurationSeconds * 1000)
} catch (error) {
console.error("Falha ao iniciar gravação:", error)
onError?.("Não foi possível iniciar a gravação de áudio.")
cleanup()
}
}, [
audioBitsPerSecond,
cleanup,
isProcessing,
isRecording,
levelBars,
maxDurationSeconds,
maxFileSizeBytes,
onAudioReady,
onError,
stopRecording,
updateLevels,
])
return {
isRecording,
isProcessing,
durationSeconds,
levels,
startRecording,
stopRecording,
cancelRecording,
}
}

View file

@ -1,206 +0,0 @@
/**
* Hooks customizados para queries/mutations do Convex com token de maquina
*
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
* para as queries e mutations do Convex, proporcionando uma API simples
* e reativa para os componentes de chat.
*/
import { useQuery, useMutation, useAction } from "convex/react"
import { api } from "@convex/_generated/api"
import type { Id } from "@convex/_generated/dataModel"
import { useMachineToken } from "./ConvexMachineProvider"
// ============================================
// TIPOS
// ============================================
export type MachineSession = {
sessionId: Id<"liveChatSessions">
ticketId: Id<"tickets">
ticketRef: number
ticketSubject: string
agentName: string
agentEmail?: string
agentAvatarUrl?: string
unreadCount: number
lastActivityAt: number
startedAt: number
}
export type MachineMessage = {
id: Id<"ticketChatMessages">
body: string
authorName: string
authorAvatarUrl?: string
isFromMachine: boolean
createdAt: number
attachments: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}
export type MachineMessagesResult = {
messages: MachineMessage[]
hasSession: boolean
unreadCount: number
}
export type MachineUpdatesResult = {
hasActiveSessions: boolean
sessions: Array<{
ticketId: Id<"tickets">
ticketRef: number
unreadCount: number
lastActivityAt: number
}>
totalUnread: number
}
// ============================================
// HOOKS
// ============================================
/**
* Hook para listar sessoes ativas da maquina
* Subscription reativa - atualiza automaticamente quando ha mudancas
*/
export function useMachineSessions() {
const machineToken = useMachineToken()
const sessions = useQuery(
api.liveChat.listMachineSessions,
machineToken ? { machineToken } : "skip"
)
return {
sessions: sessions as MachineSession[] | undefined,
isLoading: sessions === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para listar mensagens de um ticket especifico
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
*/
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.listMachineMessages,
machineToken && ticketId
? { machineToken, ticketId, limit: options?.limit }
: "skip"
)
return {
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
isLoading: result === undefined && !!machineToken && !!ticketId,
hasToken: !!machineToken,
}
}
/**
* Hook para verificar updates (polling leve)
* Usado como fallback ou para verificar status rapidamente
*/
export function useMachineUpdates() {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.checkMachineUpdates,
machineToken ? { machineToken } : "skip"
)
return {
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
isLoading: result === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para enviar mensagem
*/
export function usePostMachineMessage() {
const machineToken = useMachineToken()
const postMessage = useMutation(api.liveChat.postMachineMessage)
return async (args: {
ticketId: Id<"tickets">
body: string
attachments?: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return postMessage({
machineToken,
ticketId: args.ticketId,
body: args.body,
attachments: args.attachments,
})
}
}
/**
* Hook para marcar mensagens como lidas
*/
export function useMarkMachineMessagesRead() {
const machineToken = useMachineToken()
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
return async (args: {
ticketId: Id<"tickets">
messageIds: Id<"ticketChatMessages">[]
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return markRead({
machineToken,
ticketId: args.ticketId,
messageIds: args.messageIds,
})
}
}
/**
* Hook para gerar URL de upload
*/
export function useGenerateMachineUploadUrl() {
const machineToken = useMachineToken()
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
return async (args: {
fileName: string
fileType: string
fileSize: number
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return generateUrl({
machineToken,
fileName: args.fileName,
fileType: args.fileType,
fileSize: args.fileSize,
})
}
}

View file

@ -1,36 +1,23 @@
import { ShieldAlert, Mail, RefreshCw } from "lucide-react"
import { useState } from "react"
type DeactivationScreenProps = {
companyName?: string | null
onRetry?: () => Promise<void> | void
}
export function DeactivationScreen({ onRetry }: DeactivationScreenProps) {
const [isRetrying, setIsRetrying] = useState(false)
const handleRetry = async () => {
if (isRetrying || !onRetry) return
setIsRetrying(true)
try {
await onRetry()
} finally {
setIsRetrying(false)
}
}
import { ShieldAlert, Mail } from "lucide-react"
export function DeactivationScreen({ companyName }: { companyName?: string | null }) {
return (
<div className="fixed inset-0 z-50 grid place-items-center overflow-hidden bg-neutral-950 p-6">
<div className="min-h-screen grid place-items-center bg-neutral-950 p-6">
<div className="flex w-full max-w-[720px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
<ShieldAlert className="size-4" /> Acesso bloqueado
</span>
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativado</h1>
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1>
<p className="max-w-md text-sm text-neutral-600">
Este dispositivo foi desativado 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.
</p>
{companyName ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700">
{companyName}
</span>
) : null}
</div>
<div className="w-full max-w-[520px] space-y-4">
@ -42,25 +29,12 @@ export function DeactivationScreen({ onRetry }: DeactivationScreenProps) {
</ul>
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<a
href="mailto:suporte@rever.com.br"
className="inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
>
<Mail className="size-4" /> Falar com o suporte
</a>
{onRetry && (
<button
type="button"
onClick={handleRetry}
disabled={isRetrying}
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 transition hover:bg-slate-50 disabled:opacity-50"
>
<RefreshCw className={`size-4 ${isRetrying ? "animate-spin" : ""}`} />
{isRetrying ? "Verificando..." : "Verificar novamente"}
</button>
)}
</div>
<a
href="mailto:suporte@rever.com.br"
className="mx-auto inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
>
<Mail className="size-4" /> Falar com o suporte
</a>
</div>
</div>
</div>

View file

@ -1,103 +0,0 @@
/**
* MachineStateMonitor - Componente para monitorar o estado da máquina em tempo real
*
* Este componente usa uma subscription Convex para detectar mudanças no estado da máquina:
* - Quando isActive muda para false: máquina foi desativada
* - Quando hasValidToken muda para false: máquina foi resetada (tokens revogados)
*
* O componente não renderiza nada, apenas monitora e chama callbacks quando detecta mudanças.
*/
import { useEffect, useRef } from "react"
import { useQuery, ConvexProvider } from "convex/react"
import type { ConvexReactClient } from "convex/react"
import { api } from "../convex/_generated/api"
import type { Id } from "../convex/_generated/dataModel"
type MachineStateMonitorProps = {
machineId: string
onDeactivated?: () => void
onTokenRevoked?: () => void
onReactivated?: () => void
}
function MachineStateMonitorInner({ machineId, onDeactivated, onTokenRevoked, onReactivated }: MachineStateMonitorProps) {
const machineState = useQuery(api.machines.getMachineState, {
machineId: machineId as Id<"machines">,
})
// Refs para rastrear o estado anterior e evitar chamadas duplicadas
const previousIsActive = useRef<boolean | null>(null)
const previousHasValidToken = useRef<boolean | null>(null)
const initialLoadDone = useRef(false)
useEffect(() => {
if (!machineState) return
// Na primeira carga, verifica estado inicial E armazena valores
if (!initialLoadDone.current) {
console.log("[MachineStateMonitor] Carga inicial", {
isActive: machineState.isActive,
hasValidToken: machineState.hasValidToken,
found: machineState.found,
})
// Se já estiver desativado na carga inicial, chama callback
if (machineState.isActive === false) {
console.log("[MachineStateMonitor] Máquina já estava desativada")
onDeactivated?.()
}
// Se token já estiver inválido na carga inicial, chama callback
if (machineState.hasValidToken === false) {
console.log("[MachineStateMonitor] Token já estava revogado")
onTokenRevoked?.()
}
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
initialLoadDone.current = true
return
}
// Detecta mudança de ativo para inativo
if (previousIsActive.current === true && machineState.isActive === false) {
console.log("[MachineStateMonitor] Máquina foi desativada")
onDeactivated?.()
}
// Detecta mudança de inativo para ativo (reativação)
if (previousIsActive.current === false && machineState.isActive === true) {
console.log("[MachineStateMonitor] Máquina foi reativada")
onReactivated?.()
}
// Detecta mudança de token válido para inválido
if (previousHasValidToken.current === true && machineState.hasValidToken === false) {
console.log("[MachineStateMonitor] Token foi revogado (reset)")
onTokenRevoked?.()
}
// Atualiza refs
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
}, [machineState, onDeactivated, onTokenRevoked, onReactivated])
// Este componente nao renderiza nada
return null
}
type MachineStateMonitorWithClientProps = MachineStateMonitorProps & {
client: ConvexReactClient
}
/**
* Wrapper que recebe o cliente Convex e envolve o monitor com o provider
*/
export function MachineStateMonitor({ client, ...props }: MachineStateMonitorWithClientProps) {
return (
<ConvexProvider client={client}>
<MachineStateMonitorInner {...props} />
</ConvexProvider>
)
}

View file

@ -1,121 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as alerts from "../alerts.js";
import type * as automations from "../automations.js";
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as categorySlas from "../categorySlas.js";
import type * as checklistTemplates from "../checklistTemplates.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 dashboards from "../dashboards.js";
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js";
import type * as emprestimos from "../emprestimos.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as incidents from "../incidents.js";
import type * as invites from "../invites.js";
import type * as liveChat from "../liveChat.js";
import type * as machines from "../machines.js";
import type * as metrics from "../metrics.js";
import type * as migrations from "../migrations.js";
import type * as ops from "../ops.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
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 ticketFormTemplates from "../ticketFormTemplates.js";
import type * as ticketNotifications from "../ticketNotifications.js";
import type * as tickets from "../tickets.js";
import type * as usbPolicy from "../usbPolicy.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
alerts: typeof alerts;
automations: typeof automations;
bootstrap: typeof bootstrap;
categories: typeof categories;
categorySlas: typeof categorySlas;
checklistTemplates: typeof checklistTemplates;
commentTemplates: typeof commentTemplates;
companies: typeof companies;
crons: typeof crons;
dashboards: typeof dashboards;
deviceExportTemplates: typeof deviceExportTemplates;
deviceFieldDefaults: typeof deviceFieldDefaults;
deviceFields: typeof deviceFields;
devices: typeof devices;
emprestimos: typeof emprestimos;
fields: typeof fields;
files: typeof files;
incidents: typeof incidents;
invites: typeof invites;
liveChat: typeof liveChat;
machines: typeof machines;
metrics: typeof metrics;
migrations: typeof migrations;
ops: typeof ops;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
revision: typeof revision;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
ticketFormTemplates: typeof ticketFormTemplates;
ticketNotifications: typeof ticketNotifications;
tickets: typeof tickets;
usbPolicy: typeof usbPolicy;
users: typeof users;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

View file

@ -1,23 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View file

@ -1,60 +0,0 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View file

@ -1,143 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -1,93 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

View file

@ -6,21 +6,12 @@ import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
import { ConvexReactClient } from "convex/react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen"
import { MachineStateMonitor } from "./components/MachineStateMonitor"
import { api } from "./convex/_generated/api"
import type { Id } from "./convex/_generated/dataModel"
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
// URL do Convex para subscription em tempo real
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineOs = {
name: string
version?: string | null
@ -313,7 +304,7 @@ function App() {
const [token, setToken] = useState<string | null>(null)
const [config, setConfig] = useState<AgentConfig | null>(null)
const [profile, setProfile] = useState<MachineProfile | null>(null)
const [logoSrc, setLogoSrc] = useState<string>("/logo-raven.png")
const [logoSrc, setLogoSrc] = useState<string>(() => `${appUrl}/logo-raven.png`)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<string | null>(null)
@ -330,9 +321,6 @@ function App() {
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
const lastHealAtRef = useRef(0)
// Cliente Convex para monitoramento em tempo real do estado da maquina
const [convexClient, setConvexClient] = useState<ConvexReactClient | null>(null)
const [provisioningCode, setProvisioningCode] = useState("")
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
const [companyName, setCompanyName] = useState("")
@ -422,15 +410,8 @@ function App() {
status: "online",
intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300,
})
// Iniciar sistema de chat apos o agente
await invoke("start_chat_polling", {
baseUrl: apiBaseUrl,
convexUrl: "https://convex.esdrasrenan.com.br",
token: data.machineToken,
})
logDesktop("chat:started")
} catch (err) {
console.error("Falha ao reiniciar heartbeat/chat", err)
console.error("Falha ao reiniciar heartbeat", err)
}
return nextConfig
@ -605,15 +586,8 @@ function App() {
status: "online",
intervalSeconds: 300,
})
// Iniciar sistema de chat apos o agente
await invoke("start_chat_polling", {
baseUrl: apiBaseUrl,
convexUrl: "https://convex.esdrasrenan.com.br",
token,
})
logDesktop("chat:started:validation")
} catch (err) {
console.error("Falha ao iniciar heartbeat/chat em segundo plano", err)
console.error("Falha ao iniciar heartbeat em segundo plano", err)
}
const payload = await res.clone().json().catch(() => null)
if (payload && typeof payload === "object" && "machine" in payload) {
@ -705,88 +679,6 @@ useEffect(() => {
rustdeskInfoRef.current = rustdeskInfo
}, [rustdeskInfo])
// Cria/destrói cliente Convex quando o token muda
useEffect(() => {
if (!token) {
if (convexClient) {
convexClient.close()
setConvexClient(null)
}
return
}
// Cria novo cliente Convex para monitoramento em tempo real
const client = new ConvexReactClient(CONVEX_URL, {
unsavedChangesWarning: false,
})
setConvexClient(client)
return () => {
client.close()
}
}, [token]) // eslint-disable-line react-hooks/exhaustive-deps
// Callbacks para quando a máquina for desativada, resetada ou reativada
const handleMachineDeactivated = useCallback(() => {
console.log("[App] Máquina foi desativada - mostrando tela de bloqueio")
setIsMachineActive(false)
}, [])
const handleMachineReactivated = useCallback(() => {
console.log("[App] Máquina foi reativada - liberando acesso")
setIsMachineActive(true)
}, [])
// Callback para o botão "Verificar novamente" na tela de desativação
// Usa o convexClient diretamente para fazer uma query manual
const handleRetryCheck = useCallback(async () => {
if (!convexClient || !config?.machineId) return
console.log("[App] Verificando estado da máquina manualmente...")
try {
const state = await convexClient.query(api.machines.getMachineState, {
machineId: config.machineId as Id<"machines">,
})
console.log("[App] Estado da máquina:", state)
if (state?.isActive) {
console.log("[App] Máquina ativa - liberando acesso")
setIsMachineActive(true)
}
} catch (err) {
console.error("[App] Erro ao verificar estado:", err)
}
}, [convexClient, config?.machineId])
const handleTokenRevoked = useCallback(async () => {
console.log("[App] Token foi revogado - voltando para tela de registro")
if (store) {
try {
await store.delete("token")
await store.delete("config")
await store.save()
} catch (err) {
console.error("Falha ao limpar store", err)
}
}
tokenVerifiedRef.current = false
autoLaunchRef.current = false
setToken(null)
setConfig(null)
setStatus(null)
setIsMachineActive(true)
setIsLaunchingSystem(false)
// Limpa campos de input para novo registro
setProvisioningCode("")
setCollabEmail("")
setCollabName("")
setValidatedCompany(null)
setCodeStatus(null)
setCompanyName("")
setError("Este dispositivo foi resetado. Informe o código de provisionamento para reconectar.")
// Força navegar de volta para a página inicial do app Tauri (não do servidor web)
// URL do app Tauri em produção é http://tauri.localhost/, em dev é http://localhost:1420/
const appUrl = import.meta.env.MODE === "production" ? "http://tauri.localhost/" : "http://localhost:1420/"
window.location.href = appUrl
}, [store])
useEffect(() => {
if (!store || !config) return
@ -1357,10 +1249,6 @@ const resolvedAppUrl = useMemo(() => {
const openSystem = useCallback(async () => {
if (!token) return
if (!isMachineActive) {
setIsLaunchingSystem(false)
return
}
setIsLaunchingSystem(true)
// Recarrega store do disco para pegar dados que o Rust salvou diretamente
@ -1420,6 +1308,7 @@ const resolvedAppUrl = useMemo(() => {
setError(null)
}
if (!currentActive) {
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
setIsLaunchingSystem(false)
return
}
@ -1427,8 +1316,14 @@ const resolvedAppUrl = useMemo(() => {
}
} else {
if (res.status === 423) {
const payload = await res.clone().json().catch(() => null)
const message =
payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string"
? ((payload as { error?: string }).error ?? "").trim()
: ""
setIsMachineActive(false)
setIsLaunchingSystem(false)
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
@ -1478,7 +1373,7 @@ const resolvedAppUrl = useMemo(() => {
const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") })
window.location.href = url
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive])
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store])
async function reprovision() {
if (!store) return
@ -1583,28 +1478,17 @@ const resolvedAppUrl = useMemo(() => {
if (!token) return
if (autoLaunchRef.current) return
if (!tokenVerifiedRef.current) return
if (!isMachineActive) return // Não redireciona se a máquina estiver desativada
autoLaunchRef.current = true
setIsLaunchingSystem(true)
openSystem()
}, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive])
}, [token, status, config?.accessRole, openSystem, tokenValidationTick])
// Quando há token persistido (dispositivo já provisionado) e ainda não
// disparamos o auto-launch, exibimos diretamente a tela de loading da
// plataforma para evitar piscar o card de resumo/inventário.
// IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real
if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) {
if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) {
return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
{/* Monitor de estado da máquina - deve rodar mesmo durante loading */}
{token && config?.machineId && convexClient && (
<MachineStateMonitor
client={convexClient}
machineId={config.machineId}
onDeactivated={handleMachineDeactivated}
onTokenRevoked={handleTokenRevoked}
/>
)}
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
<Loader2 className="size-6 animate-spin text-neutral-700" />
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever</p>
@ -1614,31 +1498,11 @@ const resolvedAppUrl = useMemo(() => {
)
}
// Monitor sempre ativo quando há token e machineId
const machineMonitor = token && config?.machineId && convexClient ? (
<MachineStateMonitor
client={convexClient}
machineId={config.machineId}
onDeactivated={handleMachineDeactivated}
onTokenRevoked={handleTokenRevoked}
onReactivated={handleMachineReactivated}
/>
) : null
// Tela de desativação (renderizada separadamente para evitar container com fundo claro)
if (token && !isMachineActive) {
return (
<>
{machineMonitor}
<DeactivationScreen companyName={companyName} onRetry={handleRetryCheck} />
</>
)
}
return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
{/* Monitor de estado da maquina em tempo real via Convex */}
{machineMonitor}
{token && !isMachineActive ? (
<DeactivationScreen companyName={companyName} />
) : (
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="mb-6 flex flex-col items-center gap-4 text-center">
<img
@ -1646,23 +1510,16 @@ const resolvedAppUrl = useMemo(() => {
alt="Logotipo Raven"
width={160}
height={160}
className="h-16 w-auto md:h-20"
className="h-14 w-auto md:h-16"
onError={() => {
if (logoFallbackRef.current) return
logoFallbackRef.current = true
setLogoSrc(`${appUrl}/logo-raven.png`)
setLogoSrc(`${appUrl}/raven.png`)
}}
/>
<div className="flex flex-col items-center gap-2">
<span className="text-lg font-semibold text-neutral-900">Raven</span>
<div className="flex flex-col items-center gap-1">
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
Plataforma de
</span>
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
Chamados
</span>
</div>
<span className="text-xs text-neutral-500">Raven</span>
<span className="text-2xl font-semibold text-neutral-900">Sistema de chamados</span>
<StatusBadge status={status} />
</div>
</div>
@ -1866,6 +1723,8 @@ const resolvedAppUrl = useMemo(() => {
</div>
)}
</div>
)}
</div>
)
}

View file

@ -19,13 +19,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"types": ["vite/client"],
/* Paths */
"baseUrl": ".",
"paths": {
"@convex/_generated/*": ["./src/convex/_generated/*"]
}
"types": ["vite/client"]
},
"include": ["src"]
}

View file

@ -1,6 +1,5 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
const host = process.env.TAURI_DEV_HOST;
@ -8,13 +7,6 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react()],
resolve: {
alias: {
// Usar arquivos _generated locais para evitar problemas de type-check
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors

View file

@ -21,7 +21,6 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@ -115,7 +114,6 @@
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -514,8 +512,6 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@ -2336,8 +2332,6 @@
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
@ -2472,8 +2466,6 @@
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -2605,55 +2597,5 @@
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
}
}

View file

@ -14,7 +14,7 @@ import {
} from "./automationsEngine"
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
import type { AutomationEmailProps } from "./reactEmail"
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail"
import { buildBaseUrl } from "./url"
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
@ -988,38 +988,19 @@ async function applyActions(
ctaLabel,
ctaUrl,
}
const html = await renderAutomationEmailHtml(emailProps)
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
to,
subject,
emailProps: {
title: emailProps.title,
message: emailProps.message,
ticket: {
reference: emailProps.ticket.reference,
subject: emailProps.ticket.subject,
status: emailProps.ticket.status ?? null,
priority: emailProps.ticket.priority ?? null,
companyName: emailProps.ticket.companyName ?? null,
requesterName: emailProps.ticket.requesterName ?? null,
assigneeName: emailProps.ticket.assigneeName ?? null,
},
ctaLabel: emailProps.ctaLabel,
ctaUrl: emailProps.ctaUrl,
},
html,
})
applied.push({
type: "SEND_EMAIL",
details: {
recipients: to,
toCount: to.length,
subject,
messagePreview: message.length > 100 ? `${message.slice(0, 100)}...` : message,
ctaTarget: effectiveTarget,
ctaLabel,
ctaUrl,
scheduledAt: Date.now(),
},
})
}

View file

@ -14,37 +14,17 @@ function normalizeTemplateDescription(input: string | null | undefined) {
return text.length > 0 ? text : null
}
type ChecklistItemType = "checkbox" | "question"
type RawTemplateItem = {
id?: string
text: string
description?: string
type?: string
options?: string[]
required?: boolean
}
type NormalizedTemplateItem = {
id: string
text: string
description?: string
type?: ChecklistItemType
options?: string[]
required?: boolean
}
function normalizeTemplateItems(
raw: RawTemplateItem[],
raw: Array<{ id?: string; text: string; required?: boolean }>,
options: { generateId?: () => string }
): NormalizedTemplateItem[] {
) {
if (!Array.isArray(raw) || raw.length === 0) {
throw new ConvexError("Adicione pelo menos um item no checklist.")
}
const generateId = options.generateId ?? (() => crypto.randomUUID())
const seen = new Set<string>()
const items: NormalizedTemplateItem[] = []
const items: Array<{ id: string; text: string; required?: boolean }> = []
for (const entry of raw) {
const id = String(entry.id ?? "").trim() || generateId()
@ -61,25 +41,8 @@ function normalizeTemplateItems(
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
}
const description = entry.description?.trim() || undefined
const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox"
const itemOptions = itemType === "question" && Array.isArray(entry.options)
? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0)
: undefined
if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) {
throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opções.`)
}
const required = typeof entry.required === "boolean" ? entry.required : true
items.push({
id,
text,
description,
type: itemType,
options: itemOptions,
required,
})
items.push({ id, text, required })
}
return items
@ -94,9 +57,6 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co
items: (template.items ?? []).map((item) => ({
id: item.id,
text: item.text,
description: item.description,
type: item.type ?? "checkbox",
options: item.options,
required: typeof item.required === "boolean" ? item.required : true,
})),
isArchived: Boolean(template.isArchived),
@ -204,9 +164,6 @@ export const create = mutation({
v.object({
id: v.optional(v.string()),
text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()),
options: v.optional(v.array(v.string())),
required: v.optional(v.boolean()),
}),
),
@ -259,9 +216,6 @@ export const update = mutation({
v.object({
id: v.optional(v.string()),
text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()),
options: v.optional(v.array(v.string())),
required: v.optional(v.boolean()),
}),
),
@ -325,52 +279,3 @@ export const remove = mutation({
return { ok: true }
},
})
// DEBUG: Query para verificar dados do template e checklist de um ticket
export const debugTemplateAndTicketChecklist = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
templateId: v.id("ticketChecklistTemplates"),
ticketId: v.optional(v.id("tickets")),
},
handler: async (ctx, { tenantId, viewerId, templateId, ticketId }) => {
await requireStaff(ctx, viewerId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
return { error: "Template nao encontrado" }
}
const templateData = {
id: String(template._id),
name: template.name,
description: template.description,
hasDescription: Boolean(template.description),
descriptionType: typeof template.description,
itemsCount: template.items?.length ?? 0,
}
let ticketData = null
if (ticketId) {
const ticket = await ctx.db.get(ticketId)
if (ticket && ticket.tenantId === tenantId) {
ticketData = {
id: String(ticket._id),
checklistCount: ticket.checklist?.length ?? 0,
checklistItems: (ticket.checklist ?? []).map((item) => ({
id: item.id,
text: item.text.substring(0, 50),
templateId: item.templateId ? String(item.templateId) : null,
templateDescription: item.templateDescription,
hasTemplateDescription: Boolean(item.templateDescription),
description: item.description,
hasDescription: Boolean(item.description),
})),
}
}
}
return { template: templateData, ticket: ticketData }
},
})

View file

@ -1,273 +0,0 @@
import { mutation, query } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { requireAdmin } from "./rbac"
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
const VALID_TIME_MODES = ["business", "calendar"] as const
type CompanySlaRuleInput = {
priority: string
categoryId?: string | null
responseTargetMinutes?: number | null
responseMode?: string | null
solutionTargetMinutes?: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
calendarType?: string | null
}
const ruleInput = v.object({
priority: v.string(),
categoryId: v.optional(v.union(v.id("ticketCategories"), v.null())),
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()),
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
})
function normalizePriority(value: string) {
const upper = value.trim().toUpperCase()
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
}
function sanitizeTime(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function normalizeMode(value?: string | null) {
if (!value) return "calendar"
const normalized = value.toLowerCase()
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
}
function normalizeThreshold(value?: number | null) {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0.8
}
const clamped = Math.min(Math.max(value, 0.1), 0.95)
return Math.round(clamped * 100) / 100
}
function normalizePauseStatuses(value?: string[] | null) {
if (!Array.isArray(value)) return ["PAUSED"]
const normalized = new Set<string>()
for (const status of value) {
if (typeof status !== "string") continue
const upper = status.trim().toUpperCase()
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
normalized.add(upper)
}
}
if (normalized.size === 0) {
normalized.add("PAUSED")
}
return Array.from(normalized)
}
// Lista todas as empresas que possuem SLA customizado
export const listCompaniesWithCustomSla = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId)
// Busca todas as configurações de SLA por empresa
const allSettings = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId))
.take(1000)
// Agrupa por companyId para evitar duplicatas
const companyIds = [...new Set(allSettings.map((s) => s.companyId))]
// Busca dados das empresas
const companies = await Promise.all(
companyIds.map(async (companyId) => {
const company = await ctx.db.get(companyId)
if (!company) return null
const rulesCount = allSettings.filter((s) => s.companyId === companyId).length
return {
companyId,
companyName: company.name,
companySlug: company.slug,
rulesCount,
}
})
)
return companies.filter(Boolean)
},
})
// Busca as regras de SLA de uma empresa específica
export const get = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.id("companies"),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
await requireAdmin(ctx, viewerId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
const records = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
// Busca nomes das categorias referenciadas
const categoryIds = [...new Set(records.filter((r) => r.categoryId).map((r) => r.categoryId!))]
const categories = await Promise.all(categoryIds.map((id) => ctx.db.get(id)))
const categoryNames = new Map(
categories.filter(Boolean).map((c) => [c!._id, c!.name])
)
return {
companyId,
companyName: company.name,
rules: records.map((record) => ({
priority: record.priority,
categoryId: record.categoryId ?? null,
categoryName: record.categoryId ? categoryNames.get(record.categoryId) ?? null : null,
responseTargetMinutes: record.responseTargetMinutes ?? null,
responseMode: record.responseMode ?? "calendar",
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
solutionMode: record.solutionMode ?? "calendar",
alertThreshold: record.alertThreshold ?? 0.8,
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
})),
}
},
})
// Salva as regras de SLA de uma empresa
export const save = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
companyId: v.id("companies"),
rules: v.array(ruleInput),
},
handler: async (ctx, { tenantId, actorId, companyId, rules }) => {
await requireAdmin(ctx, actorId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
// Valida categorias referenciadas
for (const rule of rules) {
if (rule.categoryId) {
const category = await ctx.db.get(rule.categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError(`Categoria inválida: ${rule.categoryId}`)
}
}
}
const sanitized = sanitizeRules(rules)
// Remove regras existentes da empresa
const existing = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
// Insere novas regras
const now = Date.now()
for (const rule of sanitized) {
await ctx.db.insert("companySlaSettings", {
tenantId,
companyId,
categoryId: rule.categoryId ? (rule.categoryId as Id<"ticketCategories">) : undefined,
priority: rule.priority,
responseTargetMinutes: rule.responseTargetMinutes,
responseMode: rule.responseMode,
solutionTargetMinutes: rule.solutionTargetMinutes,
solutionMode: rule.solutionMode,
alertThreshold: rule.alertThreshold,
pauseStatuses: rule.pauseStatuses,
calendarType: rule.calendarType ?? undefined,
createdAt: now,
updatedAt: now,
actorId,
})
}
return { ok: true }
},
})
// Remove todas as regras de SLA de uma empresa
export const remove = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
companyId: v.id("companies"),
},
handler: async (ctx, { tenantId, actorId, companyId }) => {
await requireAdmin(ctx, actorId, tenantId)
const company = await ctx.db.get(companyId)
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada")
}
const existing = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(100)
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
return { ok: true }
},
})
function sanitizeRules(rules: CompanySlaRuleInput[]) {
// Chave única: categoryId + priority
const normalized: Map<string, ReturnType<typeof buildRule>> = new Map()
for (const rule of rules) {
const built = buildRule(rule)
const key = `${built.categoryId ?? "ALL"}-${built.priority}`
normalized.set(key, built)
}
return Array.from(normalized.values())
}
function buildRule(rule: CompanySlaRuleInput) {
const priority = normalizePriority(rule.priority)
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
return {
priority,
categoryId: rule.categoryId ?? null,
responseTargetMinutes,
responseMode: normalizeMode(rule.responseMode),
solutionTargetMinutes,
solutionMode: normalizeMode(rule.solutionMode),
alertThreshold: normalizeThreshold(rule.alertThreshold),
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
calendarType: rule.calendarType ?? null,
}
}

View file

@ -168,40 +168,7 @@ export const startSession = mutation({
createdAt: now,
})
// Iniciar timer automaticamente se nao houver sessao de trabalho ativa
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
let workSessionId: Id<"ticketWorkSessions"> | null = null
if (!ticket.activeSessionId && ticket.assigneeId) {
workSessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: ticket.assigneeId,
workType: "EXTERNAL",
startedAt: now,
})
await ctx.db.patch(ticketId, {
working: true,
activeSessionId: workSessionId,
status: "AWAITING_ATTENDANCE",
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
payload: {
actorId,
actorName: agent.name,
actorAvatar: agent.avatarUrl,
sessionId: workSessionId,
workType: "EXTERNAL",
source: "live_chat_auto",
},
createdAt: now,
})
}
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
return { sessionId, isNew: true }
},
})
@ -258,60 +225,7 @@ export const endSession = mutation({
createdAt: now,
})
// Pausar timer automaticamente se houver sessao de trabalho ativa
let workSessionPaused = false
const ticket = await ctx.db.get(session.ticketId)
if (ticket?.activeSessionId) {
const workSession = await ctx.db.get(ticket.activeSessionId)
if (workSession && !workSession.stoppedAt) {
const workDurationMs = now - workSession.startedAt
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
// Encerrar sessao de trabalho
await ctx.db.patch(ticket.activeSessionId, {
stoppedAt: now,
durationMs: workDurationMs,
pauseReason: "END_LIVE_CHAT",
pauseNote: "Pausa automática ao encerrar chat ao vivo",
})
// Atualizar ticket
await ctx.db.patch(session.ticketId, {
working: false,
activeSessionId: undefined,
status: "PAUSED",
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now,
})
// Registrar evento de pausa
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "WORK_PAUSED",
payload: {
actorId,
actorName: actor.name,
actorAvatar: actor.avatarUrl,
sessionId: workSession._id,
sessionDurationMs: workDurationMs,
workType: sessionType,
pauseReason: "END_LIVE_CHAT",
pauseReasonLabel: "Chat ao vivo encerrado",
pauseNote: "Pausa automática ao encerrar chat ao vivo",
source: "live_chat_auto",
},
createdAt: now,
})
workSessionPaused = true
}
}
return { ok: true, workSessionPaused }
return { ok: true }
},
})
@ -503,14 +417,8 @@ export const listMachineSessions = query({
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
.take(50)
// Filtrar apenas sessão problemática legada (ID hardcoded)
// Nota: lastAgentMessageAt pode ser undefined em sessões novas onde o agente ainda não enviou mensagem
const validSessions = sessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j"
)
const result = await Promise.all(
validSessions.map(async (session) => {
sessions.map(async (session) => {
const ticket = await ctx.db.get(session.ticketId)
return {
sessionId: session._id,
@ -612,18 +520,13 @@ export const checkMachineUpdates = query({
const { machine } = await validateMachineToken(ctx, args.machineToken)
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
const rawSessions = await ctx.db
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_machine_status", (q) =>
q.eq("machineId", machine._id).eq("status", "ACTIVE")
)
.take(50)
// Filtrar sessões problemáticas (sem campos obrigatórios)
const sessions = rawSessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
)
if (sessions.length === 0) {
return {
hasActiveSessions: false,
@ -860,40 +763,27 @@ export const getTicketChatHistory = query({
// Timeout de maquina offline: 5 minutos sem heartbeat
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
// Timeout de inatividade do chat: 12 horas sem atividade
// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar
const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
// Critérios de encerramento:
// 1. Máquina offline (5 min sem heartbeat)
// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online
// 3. Ticket órfão (sem máquina vinculada)
// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron)
// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat
// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens
export const autoEndInactiveSessions = mutation({
args: {},
handler: async (ctx) => {
console.log("cron: autoEndInactiveSessions iniciado")
console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)")
const now = Date.now()
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
const maxSessionsPerRun = 50
// Buscar todas as sessões ativas
const rawActiveSessions = await ctx.db
const activeSessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
.take(maxSessionsPerRun)
// Filtrar sessões problemáticas (sem campos obrigatórios)
const activeSessions = rawActiveSessions.filter(
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
)
let endedCount = 0
let checkedCount = 0
const reasons: Record<string, number> = {}
for (const session of activeSessions) {
checkedCount++
@ -922,36 +812,6 @@ export const autoEndInactiveSessions = mutation({
createdAt: now,
})
endedCount++
reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1
continue
}
// Verificar inatividade do chat (12 horas sem atividade)
// Isso tem prioridade sobre o status da máquina
const chatIsInactive = session.lastActivityAt < inactivityCutoff
if (chatIsInactive) {
await ctx.db.patch(session._id, {
status: "ENDED",
endedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "LIVE_CHAT_ENDED",
payload: {
sessionId: session._id,
agentId: session.agentId,
agentName: session.agentSnapshot?.name ?? "Sistema",
durationMs: now - session.startedAt,
startedAt: session.startedAt,
endedAt: now,
autoEnded: true,
reason: "inatividade_chat",
inactiveForMs: now - session.lastActivityAt,
},
createdAt: now,
})
endedCount++
reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1
continue
}
@ -959,7 +819,7 @@ export const autoEndInactiveSessions = mutation({
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
// Se máquina está online e chat está ativo, manter sessão
// Se máquina está online, manter sessão ativa
if (machineIsOnline) {
continue
}
@ -989,40 +849,10 @@ export const autoEndInactiveSessions = mutation({
})
endedCount++
reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1
}
const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ")
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`)
return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun }
},
})
// Mutation para corrigir sessoes antigas sem campos obrigatorios
export const fixLegacySessions = mutation({
args: {},
handler: async (ctx) => {
// IDs problematicos conhecidos - sessoes sem lastAgentMessageAt
const knownProblematicIds = [
"pd71bvfbxx7th3npdj519hcf3s7xbe2j",
]
let deleted = 0
const results: string[] = []
for (const sessionId of knownProblematicIds) {
try {
// Deletar a sessao problematica diretamente (evita erro de shape ao ler)
await ctx.db.delete(sessionId as Id<"liveChatSessions">)
deleted++
results.push(`${sessionId}: deleted`)
} catch (error) {
results.push(`${sessionId}: error - ${error}`)
}
}
console.log(`fixLegacySessions: deleted=${deleted}, results=${results.join(", ")}`)
return { deleted, results }
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`)
return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun }
},
})
@ -1037,13 +867,6 @@ const ALLOWED_MIME_TYPES = [
"image/png",
"image/gif",
"image/webp",
// Audio
"audio/webm",
"audio/ogg",
"audio/wav",
"audio/mpeg",
"audio/mp4",
"audio/x-m4a",
// Documentos
"application/pdf",
"text/plain",
@ -1055,7 +878,6 @@ const ALLOWED_MIME_TYPES = [
const ALLOWED_EXTENSIONS = [
".jpg", ".jpeg", ".png", ".gif", ".webp",
".webm", ".ogg", ".wav", ".mp3", ".m4a",
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
]
@ -1109,8 +931,7 @@ export const generateMachineUploadUrl = action({
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
}
const normalizedType = args.fileType.split(";")[0].trim().toLowerCase()
if (!ALLOWED_MIME_TYPES.includes(normalizedType)) {
if (!ALLOWED_MIME_TYPES.includes(args.fileType)) {
throw new ConvexError("Tipo MIME não permitido")
}

View file

@ -1,276 +0,0 @@
import { mutation, query, internalMutation } from "./_generated/server"
import { v } from "convex/values"
import type { Id } from "./_generated/dataModel"
// Tipo para software recebido do agente
type SoftwareInput = {
name: string
version?: string
publisher?: string
source?: string
}
// Upsert de softwares de uma maquina (chamado pelo heartbeat)
export const syncFromHeartbeat = internalMutation({
args: {
tenantId: v.string(),
machineId: v.id("machines"),
software: v.array(
v.object({
name: v.string(),
version: v.optional(v.string()),
publisher: v.optional(v.string()),
source: v.optional(v.string()),
})
),
},
handler: async (ctx, { tenantId, machineId, software }) => {
const now = Date.now()
// Busca softwares existentes da maquina
const existing = await ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.collect()
const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s]))
// Processa cada software recebido
const seenKeys = new Set<string>()
for (const item of software) {
if (!item.name || item.name.trim().length === 0) continue
const nameLower = item.name.toLowerCase().trim()
const key = `${nameLower}|${item.version ?? ""}`
seenKeys.add(key)
const existingDoc = existingMap.get(key)
if (existingDoc) {
// Atualiza lastSeenAt se ja existe
await ctx.db.patch(existingDoc._id, {
lastSeenAt: now,
publisher: item.publisher || existingDoc.publisher,
source: item.source || existingDoc.source,
})
} else {
// Cria novo registro
await ctx.db.insert("machineSoftware", {
tenantId,
machineId,
name: item.name.trim(),
nameLower,
version: item.version?.trim() || undefined,
publisher: item.publisher?.trim() || undefined,
source: item.source?.trim() || undefined,
detectedAt: now,
lastSeenAt: now,
})
}
}
// Remove softwares que nao foram vistos (desinstalados)
// So remove se o software nao foi visto nas ultimas 24 horas
const staleThreshold = now - 24 * 60 * 60 * 1000
for (const doc of existing) {
const key = `${doc.nameLower}|${doc.version ?? ""}`
if (!seenKeys.has(key) && doc.lastSeenAt < staleThreshold) {
await ctx.db.delete(doc._id)
}
}
return { processed: software.length }
},
})
// Lista softwares de uma maquina com paginacao e filtros
export const listByMachine = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
machineId: v.id("machines"),
search: v.optional(v.string()),
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, { machineId, search, limit = 50, cursor }) => {
const pageLimit = Math.min(limit, 100)
let query = ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
// Coleta todos e filtra em memoria (Convex nao suporta LIKE)
const all = await query.collect()
// Filtra por search se fornecido
let filtered = all
if (search && search.trim().length > 0) {
const searchLower = search.toLowerCase().trim()
filtered = all.filter(
(s) =>
s.nameLower.includes(searchLower) ||
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
(s.version && s.version.toLowerCase().includes(searchLower))
)
}
// Ordena por nome
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
// Paginacao manual
let startIndex = 0
if (cursor) {
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
if (cursorIndex >= 0) {
startIndex = cursorIndex + 1
}
}
const page = filtered.slice(startIndex, startIndex + pageLimit)
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
return {
items: page.map((s) => ({
id: s._id,
name: s.name,
version: s.version ?? null,
publisher: s.publisher ?? null,
source: s.source ?? null,
detectedAt: s.detectedAt,
lastSeenAt: s.lastSeenAt,
})),
total: filtered.length,
nextCursor,
}
},
})
// Lista softwares de todas as maquinas de um tenant (para admin)
export const listByTenant = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
search: v.optional(v.string()),
machineId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, { tenantId, search, machineId, limit = 50, cursor }) => {
const pageLimit = Math.min(limit, 100)
// Busca por tenant ou por maquina especifica
let all: Array<{
_id: Id<"machineSoftware">
tenantId: string
machineId: Id<"machines">
name: string
nameLower: string
version?: string
publisher?: string
source?: string
detectedAt: number
lastSeenAt: number
}>
if (machineId) {
all = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machineId))
.collect()
} else {
// Busca por tenant - pode ser grande, limita
all = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(5000)
}
// Filtra por search
let filtered = all
if (search && search.trim().length > 0) {
const searchLower = search.toLowerCase().trim()
filtered = all.filter(
(s) =>
s.nameLower.includes(searchLower) ||
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
(s.version && s.version.toLowerCase().includes(searchLower))
)
}
// Ordena por nome
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
// Paginacao
let startIndex = 0
if (cursor) {
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
if (cursorIndex >= 0) {
startIndex = cursorIndex + 1
}
}
const page = filtered.slice(startIndex, startIndex + pageLimit)
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
// Busca nomes das maquinas
const machineIds = [...new Set(page.map((s) => s.machineId))]
const machines = await Promise.all(machineIds.map((id) => ctx.db.get(id)))
const machineNames = new Map(
machines.filter(Boolean).map((m) => [m!._id, m!.displayName || m!.hostname])
)
return {
items: page.map((s) => ({
id: s._id,
machineId: s.machineId,
machineName: machineNames.get(s.machineId) ?? "Desconhecido",
name: s.name,
version: s.version ?? null,
publisher: s.publisher ?? null,
source: s.source ?? null,
detectedAt: s.detectedAt,
lastSeenAt: s.lastSeenAt,
})),
total: filtered.length,
nextCursor,
}
},
})
// Conta softwares de uma maquina
export const countByMachine = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const software = await ctx.db
.query("machineSoftware")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.collect()
return { count: software.length }
},
})
// Conta softwares unicos por tenant (para relatorios)
export const stats = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId }) => {
const software = await ctx.db
.query("machineSoftware")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.take(10000)
const uniqueNames = new Set(software.map((s) => s.nameLower))
const machineIds = new Set(software.map((s) => s.machineId))
return {
totalInstances: software.length,
uniqueSoftware: uniqueNames.size,
machinesWithSoftware: machineIds.size,
}
},
})

View file

@ -1,6 +1,6 @@
// ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server"
import { internal, api } from "./_generated/api"
import { api } from "./_generated/api"
import { paginationOptsValidator } from "convex/server"
import { ConvexError, v, Infer } from "convex/values"
import { sha256 } from "@noble/hashes/sha2.js"
@ -331,59 +331,9 @@ async function getMachineLastHeartbeat(
return hb?.lastHeartbeatAt ?? fallback ?? null
}
// Campo software é muito grande e é tratado separadamente via machineSoftware
// Extrai campos importantes do extended antes de bloqueá-lo
function extractFromExtended(extended: unknown): JsonRecord {
const result: JsonRecord = {}
const sanitizedExtended = sanitizeRecord(extended)
if (!sanitizedExtended) return result
// Extrair dados do Windows
const windows = sanitizeRecord(sanitizedExtended["windows"])
if (windows) {
const windowsFields: JsonRecord = {}
// bootInfo - informacoes de reinicio
if (windows["bootInfo"]) {
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
}
// osInfo - informacoes do sistema operacional
if (windows["osInfo"]) {
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
}
// cpu, baseboard, bios, memoryModules, videoControllers, disks
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
if (windows[key]) {
windowsFields[key] = windows[key] as JsonValue
}
}
if (Object.keys(windowsFields).length > 0) {
result["windows"] = windowsFields
}
}
// Extrair dados do Linux
const linux = sanitizeRecord(sanitizedExtended["linux"])
if (linux) {
const linuxFields: JsonRecord = {}
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
if (linux[key]) {
linuxFields[key] = linux[key] as JsonValue
}
}
if (Object.keys(linuxFields).length > 0) {
result["linux"] = linuxFields
}
}
// Extrair dados do macOS
const macos = sanitizeRecord(sanitizedExtended["macos"])
if (macos) {
result["macos"] = macos as JsonValue
}
return result
}
// Campos do inventory que sao muito grandes e nao devem ser persistidos
// para evitar OOM no Convex (documentos de ~100KB cada)
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
const sanitizedPatch = sanitizeRecord(patch)
@ -391,10 +341,9 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
return current ? { ...current } : {}
}
const base: JsonRecord = current ? { ...current } : {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
if (key === "software") continue
// Filtrar campos volumosos que causam OOM
if (INVENTORY_BLOCKLIST.has(key)) continue
if (value === undefined) continue
if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
@ -444,20 +393,9 @@ function ensureString(value: unknown): string | null {
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
const record = sanitizeRecord(value)
if (!record) return null
// Extrair campos importantes do extended antes de deletá-lo
if (record["extended"]) {
const extractedExtended = extractFromExtended(record["extended"])
if (Object.keys(extractedExtended).length > 0) {
record["extended"] = extractedExtended
} else {
delete record["extended"]
}
for (const blocked of INVENTORY_BLOCKLIST) {
delete record[blocked]
}
// Deletar apenas software (extended já foi processado acima)
delete record["software"]
return record
}
@ -1018,13 +956,10 @@ export const heartbeat = mutation({
}
}
// Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata)
const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record<string, unknown> | undefined)
const sanitizedInventory = sanitizeInventoryPayload(rawInventory)
const sanitizedInventory = sanitizeInventoryPayload(args.inventory)
const currentInventory = ensureRecord(currentMetadata.inventory)
const incomingInventoryHash = hashJson(sanitizedInventory)
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
metadataPatch.inventoryHash = incomingInventoryHash
@ -1075,34 +1010,6 @@ export const heartbeat = mutation({
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
}
// Processar softwares instalados (armazenados em tabela separada)
// Os dados de software sao extraidos ANTES de sanitizar o inventory
// Usa rawInventory ja extraido anteriormente (linha ~1022)
if (rawInventory && typeof rawInventory === "object") {
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
const validSoftware = softwareArray
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
.map((item) => ({
name: typeof item.name === "string" ? item.name : "",
version: typeof item.version === "string" ? item.version : undefined,
publisher: typeof item.publisher === "string" || typeof item.source === "string"
? (item.publisher as string) || (item.source as string)
: undefined,
source: typeof item.source === "string" ? item.source : undefined,
}))
.filter((item) => item.name.length > 0)
if (validSoftware.length > 0) {
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
tenantId: machine.tenantId,
machineId: machine._id,
software: validSoftware,
})
}
}
}
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
@ -2410,44 +2317,6 @@ export const resetAgent = mutation({
},
})
/**
* Query para o desktop monitorar o estado da máquina em tempo real.
* O desktop faz subscribe nessa query e reage imediatamente quando:
* - isActive muda para false (desativação)
* - hasValidToken muda para false (reset/revogação de tokens)
*/
export const getMachineState = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
}
// Verifica se existe algum token válido (não revogado e não expirado)
const now = Date.now()
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(10)
const hasValidToken = tokens.some((token) => {
if (token.revoked) return false
if (token.expiresAt && token.expiresAt < now) return false
return true
})
return {
found: true,
isActive: machine.isActive ?? true,
hasValidToken,
status: machine.status ?? "unknown",
}
},
})
type RemoteAccessEntry = {
id: string
provider: string

View file

@ -1043,81 +1043,3 @@ export const backfillTicketSnapshots = mutation({
return { processed }
},
})
/**
* Migration para remover comentarios duplicados de troca de responsavel.
* Esses comentarios eram criados automaticamente ao trocar o responsavel,
* mas essa informacao ja aparece na linha do tempo (ticketEvents).
* O comentario segue o padrao: "<p><strong>Responsável atualizado:</strong>..."
*/
export const removeAssigneeChangeComments = mutation({
args: {
tenantId: v.optional(v.string()),
limit: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, limit, dryRun }) => {
const effectiveDryRun = Boolean(dryRun)
const effectiveLimit = limit && limit > 0 ? Math.min(limit, 500) : 500
// Busca comentarios internos que contenham o padrao de troca de responsavel
const comments = tenantId && tenantId.trim().length > 0
? await ctx.db.query("ticketComments").take(5000)
: await ctx.db.query("ticketComments").take(5000)
// Filtrar comentarios que sao de troca de responsavel
const assigneeChangePattern = "<p><strong>Responsável atualizado:</strong>"
const toDelete = comments.filter((comment) => {
if (comment.visibility !== "INTERNAL") return false
if (typeof comment.body !== "string") return false
return comment.body.includes(assigneeChangePattern)
})
// Filtrar por tenant se especificado
let filtered = toDelete
if (tenantId && tenantId.trim().length > 0) {
const ticketIds = new Set<string>()
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(10000)
for (const t of tickets) {
ticketIds.add(t._id)
}
filtered = toDelete.filter((c) => ticketIds.has(c.ticketId))
}
const limitedComments = filtered.slice(0, effectiveLimit)
let deleted = 0
let eventsDeleted = 0
for (const comment of limitedComments) {
if (!effectiveDryRun) {
// Deletar o evento COMMENT_ADDED correspondente
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
.take(500)
const matchingEvent = events.find(
(event) =>
event.type === "COMMENT_ADDED" &&
Math.abs(event.createdAt - comment.createdAt) < 1000, // mesmo timestamp (tolerancia de 1s)
)
if (matchingEvent) {
await ctx.db.delete(matchingEvent._id)
eventsDeleted += 1
}
await ctx.db.delete(comment._id)
}
deleted += 1
}
return {
dryRun: effectiveDryRun,
totalFound: filtered.length,
deleted,
eventsDeleted,
remaining: filtered.length - deleted,
}
},
})

View file

@ -154,17 +154,10 @@ export const summary = query({
const now = Date.now();
for (const ticket of tickets) {
const status = normalizeStatus(ticket.status);
const isWorking = ticket.working === true;
if (status === "PENDING") {
pending += 1;
} else if (status === "AWAITING_ATTENDANCE") {
// "Em andamento" conta apenas tickets com play ativo
if (isWorking) {
inProgress += 1;
} else {
// Tickets em atendimento sem play ativo contam como "Em aberto"
pending += 1;
}
inProgress += 1;
} else if (status === "PAUSED") {
paused += 1;
}

View file

@ -3,31 +3,8 @@ import { render } from "@react-email/render"
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
import InviteEmail, { type InviteEmailProps } from "../emails/invite-email"
import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email"
import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email"
import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email"
import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email"
import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email"
import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email"
import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email"
import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email"
import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email"
export type {
AutomationEmailProps,
SimpleNotificationEmailProps,
InviteEmailProps,
PasswordResetEmailProps,
NewLoginEmailProps,
SlaWarningEmailProps,
SlaBreachedEmailProps,
TicketCreatedEmailProps,
TicketResolvedEmailProps,
TicketAssignedEmailProps,
TicketStatusEmailProps,
TicketCommentEmailProps,
}
export type { AutomationEmailProps, SimpleNotificationEmailProps }
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
return render(<AutomationEmail {...props} />, { pretty: false })
@ -36,43 +13,3 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
}
export async function renderInviteEmailHtml(props: InviteEmailProps) {
return render(<InviteEmail {...props} />, { pretty: false })
}
export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) {
return render(<PasswordResetEmail {...props} />, { pretty: false })
}
export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) {
return render(<NewLoginEmail {...props} />, { pretty: false })
}
export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) {
return render(<SlaWarningEmail {...props} />, { pretty: false })
}
export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) {
return render(<SlaBreachedEmail {...props} />, { pretty: false })
}
export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) {
return render(<TicketCreatedEmail {...props} />, { pretty: false })
}
export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) {
return render(<TicketResolvedEmail {...props} />, { pretty: false })
}
export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) {
return render(<TicketAssignedEmail {...props} />, { pretty: false })
}
export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) {
return render(<TicketStatusEmail {...props} />, { pretty: false })
}
export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) {
return render(<TicketCommentEmail {...props} />, { pretty: false })
}

View file

@ -161,8 +161,11 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks
}
}
function logDashboardProgress(_processed: number, _tenantId: string) {
// Log de progresso removido para reduzir ruido no console
function logDashboardProgress(processed: number, tenantId: string) {
const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024));
console.log(
`[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`,
);
}
function mapToChronologicalSeries(map: Map<string, number>) {
@ -2403,20 +2406,18 @@ export const companyOverview = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
companyId: v.id("companies"),
range: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId);
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) {
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
}
// Buscar dados da empresa selecionada (se houver)
let company: Doc<"companies"> | null = null;
if (scopedCompanyId) {
company = await ctx.db.get(scopedCompanyId);
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada");
}
const company = await ctx.db.get(companyId);
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa não encontrada");
}
const normalizedRange = (range ?? "30d").toLowerCase();
@ -2425,35 +2426,20 @@ export const companyOverview = query({
const startMs = now - rangeDays * ONE_DAY_MS;
// Limita consultas para evitar OOM em empresas muito grandes
const tickets = scopedCompanyId
? await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(2000)
: await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(2000);
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(2000);
const machines = scopedCompanyId
? await ctx.db
.query("machines")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(1000)
: await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(1000);
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(1000);
const users = scopedCompanyId
? await ctx.db
.query("users")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
.take(500)
: await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500);
const users = await ctx.db
.query("users")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.take(500);
const statusCounts = {} as Record<string, number>;
const priorityCounts = {} as Record<string, number>;
@ -2548,13 +2534,11 @@ export const companyOverview = query({
});
return {
company: company
? {
id: company._id,
name: company.name,
isAvulso: company.isAvulso ?? false,
}
: null,
company: {
id: company._id,
name: company.name,
isAvulso: company.isAvulso ?? false,
},
rangeDays,
generatedAt: now,
tickets: {

View file

@ -82,7 +82,6 @@ export default defineSchema({
contacts: v.optional(v.any()),
locations: v.optional(v.any()),
sla: v.optional(v.any()),
reopenWindowDays: v.optional(v.number()),
tags: v.optional(v.array(v.string())),
customFields: v.optional(v.any()),
notes: v.optional(v.string()),
@ -200,11 +199,7 @@ export default defineSchema({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), // minutes
responseMode: v.optional(v.string()), // "business" | "calendar"
timeToResolution: v.optional(v.number()), // minutes
solutionMode: v.optional(v.string()), // "business" | "calendar"
alertThreshold: v.optional(v.number()), // 0.1 a 0.95
pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA
}).index("by_tenant_name", ["tenantId", "name"]),
tickets: defineTable({
@ -319,15 +314,10 @@ export default defineSchema({
v.object({
id: v.string(),
text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()), // "checkbox" | "question"
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
answer: v.optional(v.string()), // Resposta selecionada para tipo "question"
done: v.boolean(),
required: v.optional(v.boolean()),
templateId: v.optional(v.id("ticketChecklistTemplates")),
templateItemId: v.optional(v.string()),
templateDescription: v.optional(v.string()), // Descricao do template (copiada ao aplicar)
createdAt: v.optional(v.number()),
createdBy: v.optional(v.id("users")),
doneAt: v.optional(v.number()),
@ -488,7 +478,6 @@ export default defineSchema({
startedAt: v.number(),
endedAt: v.optional(v.number()),
lastActivityAt: v.number(),
lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel)
unreadByMachine: v.optional(v.number()),
unreadByAgent: v.optional(v.number()),
})
@ -598,29 +587,6 @@ export default defineSchema({
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
.index("by_tenant_category", ["tenantId", "categoryId"]),
// SLA por empresa - permite configurar políticas de SLA específicas por cliente
// Quando um ticket é criado, o sistema busca primeiro aqui antes de usar categorySlaSettings
companySlaSettings: defineTable({
tenantId: v.string(),
companyId: v.id("companies"),
// Se categoryId for null, aplica-se a todas as categorias da empresa
categoryId: v.optional(v.id("ticketCategories")),
priority: v.string(), // URGENT, HIGH, MEDIUM, LOW, DEFAULT
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()), // "business" | "calendar"
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()), // "business" | "calendar"
alertThreshold: v.optional(v.number()), // 0.1 a 0.95 (ex: 0.8 = 80%)
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
actorId: v.optional(v.id("users")),
})
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_company_category", ["tenantId", "companyId", "categoryId"])
.index("by_tenant_company_category_priority", ["tenantId", "companyId", "categoryId", "priority"]),
ticketFields: defineTable({
tenantId: v.string(),
key: v.string(),
@ -692,9 +658,6 @@ export default defineSchema({
v.object({
id: v.string(),
text: v.string(),
description: v.optional(v.string()),
type: v.optional(v.string()), // "checkbox" | "question"
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
required: v.optional(v.boolean()),
})
),
@ -825,25 +788,6 @@ export default defineSchema({
})
.index("by_machine", ["machineId"]),
// Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao
// Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada
machineSoftware: defineTable({
tenantId: v.string(),
machineId: v.id("machines"),
name: v.string(),
nameLower: v.string(), // Para busca case-insensitive
version: v.optional(v.string()),
publisher: v.optional(v.string()),
source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc
installedAt: v.optional(v.number()), // Data de instalacao (se disponivel)
detectedAt: v.number(), // Quando foi detectado pelo agente
lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat
})
.index("by_machine", ["machineId"])
.index("by_machine_name", ["machineId", "nameLower"])
.index("by_tenant_name", ["tenantId", "nameLower"])
.index("by_tenant_machine", ["tenantId", "machineId"]),
machineTokens: defineTable({
tenantId: v.string(),
machineId: v.id("machines"),

View file

@ -9,26 +9,6 @@ function normalizeName(value: string) {
return value.trim();
}
function normalizeMode(value?: string): "business" | "calendar" {
if (value === "business") return "business";
return "calendar";
}
function normalizeThreshold(value?: number): number {
if (value === undefined || value === null) return 0.8;
if (value < 0.1) return 0.1;
if (value > 0.95) return 0.95;
return value;
}
const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const;
function normalizePauseStatuses(statuses?: string[]): string[] {
if (!statuses || statuses.length === 0) return ["PAUSED"];
const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number]));
return filtered.length > 0 ? filtered : ["PAUSED"];
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
@ -55,11 +35,7 @@ export const list = query({
name: policy.name,
description: policy.description ?? "",
timeToFirstResponse: policy.timeToFirstResponse ?? null,
responseMode: policy.responseMode ?? "calendar",
timeToResolution: policy.timeToResolution ?? null,
solutionMode: policy.solutionMode ?? "calendar",
alertThreshold: policy.alertThreshold ?? 0.8,
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
}));
},
});
@ -71,14 +47,9 @@ export const create = mutation({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
@ -97,11 +68,7 @@ export const create = mutation({
name: trimmed,
description,
timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
});
return id;
},
@ -115,14 +82,9 @@ export const update = mutation({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
@ -144,11 +106,7 @@ export const update = mutation({
name: trimmed,
description,
timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
});
},
});

View file

@ -1,38 +1,21 @@
import type { Id } from "./_generated/dataModel"
export type ChecklistItemType = "checkbox" | "question"
export type TicketChecklistItem = {
id: string
text: string
description?: string
type?: ChecklistItemType
options?: string[] // Para tipo "question": ["Sim", "Nao", ...]
answer?: string // Resposta selecionada para tipo "question"
done: boolean
required?: boolean
templateId?: Id<"ticketChecklistTemplates">
templateItemId?: string
templateDescription?: string // Descricao do template (copiada ao aplicar)
createdAt?: number
createdBy?: Id<"users">
doneAt?: number
doneBy?: Id<"users">
}
export type TicketChecklistTemplateItem = {
id: string
text: string
description?: string
type?: string // "checkbox" | "question" - string para compatibilidade com schema
options?: string[]
required?: boolean
}
export type TicketChecklistTemplateLike = {
_id: Id<"ticketChecklistTemplates">
description?: string
items: TicketChecklistTemplateItem[]
items: Array<{ id: string; text: string; required?: boolean }>
}
export function normalizeChecklistText(input: string) {
@ -70,18 +53,13 @@ export function applyChecklistTemplateToItems(
const key = `${String(template._id)}:${templateItemId}`
if (existingKeys.has(key)) continue
existingKeys.add(key)
const itemType = tplItem.type ?? "checkbox"
next.push({
id: generateId(),
text,
description: tplItem.description,
type: itemType as ChecklistItemType,
options: itemType === "question" ? tplItem.options : undefined,
done: false,
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
templateId: template._id,
templateItemId,
templateDescription: template.description,
createdAt: now,
createdBy: options.actorId,
})

View file

@ -8,45 +8,6 @@ import { v } from "convex/values"
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
import { buildBaseUrl } from "./url"
// API do Next.js para verificar preferências
async function sendViaNextApi(params: {
type: string
to: { email: string; name?: string; userId?: string }
subject: string
data: Record<string, unknown>
tenantId?: string
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
const baseUrl = buildBaseUrl()
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
if (!token) {
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
return { success: false, reason: "no_token" }
}
try {
const response = await fetch(`${baseUrl}/api/notifications/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
console.error("[ticketNotifications] Erro na API:", error)
return { success: false, reason: "api_error" }
}
return await response.json()
} catch (error) {
console.error("[ticketNotifications] Erro ao chamar API:", error)
return { success: false, reason: "fetch_error" }
}
}
function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
@ -320,109 +281,25 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
}
}
export const sendTicketCreatedEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
priority: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityLabel = priorityLabels[priority] ?? priority
const mailSubject = `Novo chamado #${reference} aberto`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_created",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
status: "Pendente",
priority: priorityLabel,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket created email")
return { skipped: true }
}
const html = await renderSimpleNotificationEmailHtml({
title: `Novo chamado #${reference} aberto`,
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
ctaLabel: "Ver chamado",
ctaUrl: url,
})
await sendSmtpMail(smtp, to, mailSubject, html)
return { ok: true }
},
})
export const sendPublicCommentEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "comment_public",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket comment email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
const html = await renderSimpleNotificationEmailHtml({
title: `Nova atualização no seu chamado #${reference}`,
message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
ctaLabel: "Abrir e responder",
ctaUrl: url,
})
@ -434,45 +311,22 @@ export const sendPublicCommentEmail = action({
export const sendResolvedEmail = action({
args: {
to: v.string(),
userId: v.optional(v.string()),
userName: v.optional(v.string()),
ticketId: v.string(),
reference: v.number(),
subject: v.string(),
tenantId: v.optional(v.string()),
},
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
// Tenta usar a API do Next.js para verificar preferências
const apiResult = await sendViaNextApi({
type: "ticket_resolved",
to: { email: to, name: userName, userId },
subject: mailSubject,
data: {
reference,
subject,
viewUrl: url,
},
tenantId,
})
if (apiResult.success || apiResult.skipped) {
return apiResult
}
// Fallback: envia diretamente se a API falhar
handler: async (_ctx, { to, ticketId, reference, subject }) => {
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping ticket resolution email")
return { skipped: true }
}
const baseUrl = buildBaseUrl()
const url = `${baseUrl}/portal/tickets/${ticketId}`
const mailSubject = `Seu chamado #${reference} foi encerrado`
const html = await renderSimpleNotificationEmailHtml({
title: `Chamado #${reference} encerrado`,
message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
ctaLabel: "Ver detalhes",
ctaUrl: url,
})
@ -485,23 +339,9 @@ export const sendAutomationEmail = action({
args: {
to: v.array(v.string()),
subject: v.string(),
emailProps: v.object({
title: v.string(),
message: v.string(),
ticket: v.object({
reference: v.number(),
subject: v.string(),
status: v.optional(v.union(v.string(), v.null())),
priority: v.optional(v.union(v.string(), v.null())),
companyName: v.optional(v.union(v.string(), v.null())),
requesterName: v.optional(v.union(v.string(), v.null())),
assigneeName: v.optional(v.union(v.string(), v.null())),
}),
ctaLabel: v.string(),
ctaUrl: v.string(),
}),
html: v.string(),
},
handler: async (_ctx, { to, subject, emailProps }) => {
handler: async (_ctx, { to, subject, html }) => {
const smtp = buildSmtpConfig()
if (!smtp) {
console.warn("SMTP not configured; skipping automation email")
@ -517,45 +357,10 @@ export const sendAutomationEmail = action({
return { skipped: true, reason: "no_recipients" }
}
// Renderiza o HTML aqui (ambiente Node.js suporta imports dinâmicos)
const { renderAutomationEmailHtml } = await import("./reactEmail")
const html = await renderAutomationEmailHtml({
title: emailProps.title,
message: emailProps.message,
ticket: {
reference: emailProps.ticket.reference,
subject: emailProps.ticket.subject,
status: emailProps.ticket.status ?? null,
priority: emailProps.ticket.priority ?? null,
companyName: emailProps.ticket.companyName ?? null,
requesterName: emailProps.ticket.requesterName ?? null,
assigneeName: emailProps.ticket.assigneeName ?? null,
},
ctaLabel: emailProps.ctaLabel,
ctaUrl: emailProps.ctaUrl,
})
const results: Array<{ recipient: string; sent: boolean; error?: string }> = []
for (const recipient of recipients) {
try {
await sendSmtpMail(smtp, recipient, subject, html)
results.push({ recipient, sent: true })
console.log(`[automation-email] Enviado para ${recipient}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
results.push({ recipient, sent: false, error: errorMessage })
console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`)
}
await sendSmtpMail(smtp, recipient, subject, html)
}
const sent = results.filter((r) => r.sent).length
const failed = results.filter((r) => !r.sent).length
if (failed > 0) {
console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`)
}
return { ok: sent > 0, sent, failed, results }
return { ok: true, sent: recipients.length }
},
})

View file

@ -38,7 +38,6 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento",
END_LIVE_CHAT: "Chat ao vivo encerrado",
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
};
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
@ -273,74 +272,25 @@ async function resolveTicketSlaSnapshot(
ctx: AnyCtx,
tenantId: string,
category: Doc<"ticketCategories"> | null,
priority: string,
companyId?: Id<"companies"> | null
priority: string
): Promise<TicketSlaSnapshot | null> {
if (!category) {
return null;
}
const normalizedPriority = priority.trim().toUpperCase();
// 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado)
let rule: {
responseTargetMinutes?: number;
responseMode?: string;
solutionTargetMinutes?: number;
solutionMode?: string;
alertThreshold?: number;
pauseStatuses?: string[];
} | null = null;
if (companyId) {
// Tenta: empresa + categoria + prioridade
rule = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority)
const rule =
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
)
.first();
// Fallback: empresa + categoria + DEFAULT
if (!rule) {
rule = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first();
}
// Fallback: empresa + todas categorias (categoryId null) + prioridade
if (!rule) {
const allCategoriesRules = await ctx.db
.query("companySlaSettings")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
.filter((q) => q.eq(q.field("categoryId"), undefined))
.take(10);
rule = allCategoriesRules.find((r) => r.priority === normalizedPriority) ??
allCategoriesRules.find((r) => r.priority === "DEFAULT") ??
null;
}
}
// 2. Se não encontrou SLA da empresa, usa SLA da categoria (comportamento padrão)
if (!rule) {
rule =
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
)
.first()) ??
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first());
}
.first()) ??
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first());
if (!rule) {
return null;
}
@ -922,6 +872,23 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
}
}
export function buildAssigneeChangeComment(
reason: string,
context: { previousName: string; nextName: string },
): string {
const normalized = reason.replace(/\r\n/g, "\n").trim();
const lines = normalized
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const previous = escapeHtml(context.previousName || "Não atribuído");
const next = escapeHtml(context.nextName || "Não atribuído");
const reasonHtml = lines.length
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
: `<p>—</p>`;
return `<p><strong>Responsável atualizado:</strong> ${previous}${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
}
function truncateSubject(subject: string) {
if (subject.length <= 60) return subject
return `${subject.slice(0, 57)}`
@ -2131,15 +2098,10 @@ export const getById = query({
? t.checklist.map((item) => ({
id: item.id,
text: item.text,
description: item.description ?? undefined,
type: item.type ?? "checkbox",
options: item.options ?? undefined,
answer: item.answer ?? undefined,
done: item.done,
required: typeof item.required === "boolean" ? item.required : true,
templateId: item.templateId ? String(item.templateId) : undefined,
templateItemId: item.templateItemId ?? undefined,
templateDescription: item.templateDescription ?? undefined,
createdAt: item.createdAt ?? undefined,
createdBy: item.createdBy ? String(item.createdBy) : undefined,
doneAt: item.doneAt ?? undefined,
@ -2375,7 +2337,7 @@ export const create = mutation({
avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined,
}
// Resolve a empresa primeiro para poder verificar SLA específico
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
if (!companyDoc && machineDoc?.companyId) {
const candidateCompany = await ctx.db.get(machineDoc.companyId)
@ -2387,8 +2349,6 @@ export const create = mutation({
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
: undefined
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined
// Resolve SLA passando companyId para verificar regras específicas da empresa
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority, resolvedCompanyId)
let checklist = manualChecklist
for (const templateId of args.checklistTemplateIds ?? []) {
@ -2496,28 +2456,6 @@ export const create = mutation({
createdAt: now,
});
// Notificação por e-mail: ticket criado para o solicitante
try {
const requesterEmail = requester?.email
if (requesterEmail) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
to: requesterEmail,
userId: String(requester._id),
userName: requester.name ?? undefined,
ticketId: String(id),
reference: nextRef,
subject,
priority: args.priority,
tenantId: args.tenantId,
})
}
}
} catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e)
}
if (initialAssigneeId && initialAssignee) {
await ctx.db.insert("ticketEvents", {
ticketId: id,
@ -2709,49 +2647,6 @@ export const setChecklistItemRequired = mutation({
},
});
export const setChecklistItemAnswer = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
answer: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
const ticket = await ctx.db.get(ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado");
}
const ticketDoc = ticket as Doc<"tickets">;
await requireTicketStaff(ctx, actorId, ticketDoc);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
const index = checklist.findIndex((item) => item.id === itemId);
if (index < 0) {
throw new ConvexError("Item do checklist não encontrado.");
}
const item = checklist[index]!;
if (item.type !== "question") {
throw new ConvexError("Este item não é uma pergunta.");
}
const now = Date.now();
const normalizedAnswer = answer?.trim() ?? "";
const isDone = normalizedAnswer.length > 0;
const nextChecklist = checklist.map((it) => {
if (it.id !== itemId) return it;
if (isDone) {
return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
}
return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const removeChecklistItem = mutation({
args: {
ticketId: v.id("tickets"),
@ -2805,34 +2700,6 @@ export const completeAllChecklistItems = mutation({
},
});
export const uncompleteAllChecklistItems = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, actorId }) => {
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);
ensureChecklistEditor(viewer);
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
if (checklist.length === 0) return { ok: true };
const now = Date.now();
const nextChecklist = checklist.map((item) => {
if (item.done === false) return item;
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
});
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
return { ok: true };
},
});
export const applyChecklistTemplate = mutation({
args: {
ticketId: v.id("tickets"),
@ -2984,19 +2851,15 @@ export const addComment = mutation({
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
// Notificação por e-mail: comentário público para o solicitante
try {
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const snapshotEmail = requesterSnapshot?.email
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName: requesterSnapshot?.name ?? undefined,
ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
})
}
}
@ -3227,18 +3090,7 @@ export async function resolveTicketHandler(
throw new ConvexError("Chamado vinculado não encontrado")
}
// Buscar prazo de reabertura da empresa do ticket (se existir)
let companyReopenDays: number | null = null
if (ticketDoc.companyId) {
const company = await ctx.db.get(ticketDoc.companyId)
if (company && typeof company.reopenWindowDays === "number") {
companyReopenDays = company.reopenWindowDays
}
}
// Prioridade: 1) valor passado explicitamente, 2) valor da empresa, 3) padrão
const effectiveReopenDays = reopenWindowDays ?? companyReopenDays
const reopenDays = resolveReopenWindowDays(effectiveReopenDays)
const reopenDays = resolveReopenWindowDays(reopenWindowDays)
const reopenDeadline = computeReopenDeadline(now, reopenDays)
const normalizedStatus = "RESOLVED"
const relatedIdList = Array.from(
@ -3275,21 +3127,16 @@ export async function resolveTicketHandler(
// Notificação por e-mail: encerramento do chamado
try {
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
const email = requesterDoc?.email || requesterSnapshot?.email || null
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
if (email) {
const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email,
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
userName,
ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "",
tenantId: ticketDoc.tenantId,
})
}
}
@ -3526,6 +3373,38 @@ export const changeAssignee = mutation({
createdAt: now,
});
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,
})
}
},
});
@ -3855,8 +3734,6 @@ export const postChatMessage = mutation({
await ctx.db.patch(ticketId, { updatedAt: now })
// Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
// IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions
// O Convex faz retry automatico em caso de OCC conflict
const actorRole = participant.role?.toUpperCase() ?? ""
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
const activeSession = await ctx.db
@ -3866,15 +3743,10 @@ export const postChatMessage = mutation({
.first()
if (activeSession) {
// Refetch para garantir valor mais recente (OCC protection)
const freshSession = await ctx.db.get(activeSession._id)
if (freshSession) {
await ctx.db.patch(activeSession._id, {
unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1,
lastActivityAt: now,
lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente
})
}
await ctx.db.patch(activeSession._id, {
unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1,
lastActivityAt: now,
})
}
}

View file

@ -279,86 +279,6 @@ export const deleteUser = mutation({
},
});
/**
* Atualiza o avatar de um usuário.
* Passa avatarUrl como null para remover o avatar.
* Também atualiza os snapshots em comentários e tickets.
*/
export const updateAvatar = mutation({
args: {
tenantId: v.string(),
email: v.string(),
avatarUrl: v.union(v.string(), v.null()),
},
handler: async (ctx, { tenantId, email, avatarUrl }) => {
const user = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
.first()
if (!user) {
return { status: "not_found" }
}
// Atualiza o avatar do usuário - usa undefined para remover o campo
const normalizedAvatarUrl = avatarUrl ?? undefined
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
// Cria snapshot base sem avatarUrl se for undefined
// Isso garante que o campo seja realmente removido do snapshot
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
name: user.name,
email: user.email,
}
if (normalizedAvatarUrl !== undefined) {
baseSnapshot.avatarUrl = normalizedAvatarUrl
}
if (user.teams && user.teams.length > 0) {
baseSnapshot.teams = user.teams
}
// Atualiza snapshots em comentários
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_author", (q) => q.eq("authorId", user._id))
.take(10000)
if (comments.length > 0) {
await Promise.all(
comments.map(async (comment) => {
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
}),
)
}
// Atualiza snapshots de requester em tickets
const requesterTickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", user._id))
.take(10000)
if (requesterTickets.length > 0) {
for (const t of requesterTickets) {
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
}
}
// Atualiza snapshots de assignee em tickets
const assigneeTickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", user._id))
.take(10000)
if (assigneeTickets.length > 0) {
for (const t of assigneeTickets) {
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
}
}
return { status: "updated", avatarUrl: normalizedAvatarUrl }
},
})
export const assignCompany = mutation({
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
handler: async (ctx, { tenantId, email, companyId, actorId }) => {

View file

@ -1,11 +1,11 @@
# Deploy Manual via VPS
## Acesso rápido
- Host: 154.12.253.40
- Host: 31.220.78.20
- Usuário: root
- Caminho do projeto: /srv/apps/sistema
- Chave SSH (local): ./codex_ed25519 (chmod 600)
- Login: `ssh -i ./codex_ed25519 root@154.12.253.40`
- Login: `ssh -i ./codex_ed25519 root@31.220.78.20`
## Passo a passo resumido
1. Conectar na VPS usando o comando acima.

View file

@ -1,4 +1,4 @@
# Guia de Desenvolvimento — 18/12/2025
# Guia de Desenvolvimento — 18/10/2025
Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções.
@ -6,7 +6,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
- **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback).
- **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun.
- **Next.js 16**: Projeto roda em `next@16.0.10` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
- **Next.js 16**: Projeto roda em `next@16.0.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
- **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente.
- **Banco DEV**: PostgreSQL local (Docker recomendado). Defina `DATABASE_URL` apontando para seu PostgreSQL.
- **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases.
@ -47,7 +47,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
## Next.js 16 (estável)
- Mantemos o projeto em `next@16.0.10`, com React 19 e o App Router completo.
- Mantemos o projeto em `next@16.0.8`, com React 19 e o App Router completo.
- **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas.
- **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`.
@ -200,8 +200,8 @@ PY
## Referências úteis
- **Deploy (Swarm)**: veja `docs/OPERATIONS.md`.
- **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.md`.
- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`.
- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`.
- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`.
> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem.

View file

@ -1,296 +0,0 @@
# Forgejo CI/CD - Documentacao
Este documento descreve a configuracao do Forgejo como alternativa ao GitHub Actions para CI/CD self-hosted.
## Por que Forgejo?
A partir de marco de 2026, o GitHub passara a cobrar $0.002 por minuto de execucao em self-hosted runners. O Forgejo Actions oferece a mesma experiencia visual e funcionalidade sem custo adicional.
## Arquitetura
```
Claude Code / VS Code
|
Git local
|
git push origin main (GitHub - backup)
git push forgejo main (Forgejo - CI/CD)
|
Forgejo (git.esdrasrenan.com.br)
|
Forgejo Actions (dispara automaticamente)
|
Forgejo Runner (VPS)
|
Docker Swarm deploy
```
**Fluxo:** Push para ambos os remotes. O push para `forgejo` dispara o CI/CD.
```bash
# Push para ambos (recomendado)
git push origin main && git push forgejo main
# Ou use o alias configurado
git push-all
```
## URLs e Credenciais
| Servico | URL | Usuario |
|---------|-----|---------|
| Forgejo UI | https://git.esdrasrenan.com.br | esdras |
| Forgejo SSH | git@git.esdrasrenan.com.br:2222 | - |
| Actions | https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions | - |
**Senha inicial:** `ForgejoAdmin2025!` (altere apos primeiro acesso)
## Estrutura de Arquivos
```
projeto/
├── .forgejo/
│ └── workflows/
│ ├── ci-cd-web-desktop.yml # Deploy principal (VPS + Convex)
│ └── quality-checks.yml # Lint, test, build
├── .github/
│ └── workflows/ # Workflows originais do GitHub
│ └── ...
└── forgejo/
├── stack.yml # Stack Docker do Forgejo
└── setup-runner.sh # Script de setup do runner
```
## Configuracao na VPS
### Forgejo Server
Rodando como servico Docker Swarm:
```bash
# Localização do stack
/srv/forgejo/stack.yml
# Comandos uteis
docker service ls --filter "name=forgejo"
docker service logs forgejo_forgejo --tail 100
docker stack deploy -c /srv/forgejo/stack.yml forgejo
```
### Forgejo Runner
Rodando como servico systemd:
```bash
# Localização
/srv/forgejo-runner/
# Arquivos
/srv/forgejo-runner/forgejo-runner # Binario
/srv/forgejo-runner/config.yaml # Configuracao
/srv/forgejo-runner/.runner # Registro
# Comandos uteis
systemctl status forgejo-runner
systemctl restart forgejo-runner
journalctl -u forgejo-runner -f
# Labels do runner
- ubuntu-latest:docker://node:20-bookworm
- self-hosted:host
- linux:host
- vps:host
```
## Fluxo de Trabalho
O repositorio no Forgejo recebe pushes diretos (nao e mais um mirror).
### Uso diario
```bash
# Trabalhe normalmente
git add .
git commit -m "sua mensagem"
# Push para GitHub (backup) e Forgejo (CI/CD)
git push origin main && git push forgejo main
# Acompanhe o CI/CD em:
# https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions
```
### Configurar alias (opcional)
```bash
# Adicionar alias para push em ambos
git config alias.push-all '!git push origin main && git push forgejo main'
# Usar:
git push-all
```
## Workflows Disponiveis
### ci-cd-web-desktop.yml
Triggers:
- Push na branch `main`
- Tags `v*.*.*`
- workflow_dispatch (manual)
Jobs:
1. **changes** - Detecta arquivos alterados
2. **deploy** - Deploy na VPS (Next.js + Docker Swarm, usando Bun)
3. **convex_deploy** - Deploy das functions Convex
4. ~~**desktop_release**~~ - Build do app desktop (comentado - sem runner Windows)
### quality-checks.yml
Triggers:
- Push na branch `main`
- Pull requests para `main`
Jobs:
1. **lint-test-build** - Lint, testes e build
## Diferenca do GitHub Actions
Os workflows do Forgejo sao quase identicos aos do GitHub Actions. Principais diferencas:
1. **Localizacao:** `.forgejo/workflows/` em vez de `.github/workflows/`
2. **Actions URL:** Usar `https://github.com/` prefixo nas actions
```yaml
# GitHub Actions
uses: actions/checkout@v4
# Forgejo Actions
uses: https://github.com/actions/checkout@v4
```
3. **runs-on:** Usar labels do self-hosted runner em vez de `ubuntu-latest`
```yaml
# GitHub Actions (hosted runner)
runs-on: ubuntu-latest
# Forgejo Actions (self-hosted)
runs-on: [ self-hosted, linux, vps ]
```
4. **Secrets:** Configurar em Settings > Actions > Secrets no Forgejo
## Manutencao
### Atualizar Forgejo
```bash
ssh root@154.12.253.40
cd /srv/forgejo
# Editar stack.yml para nova versao da imagem
docker stack deploy -c stack.yml forgejo
```
### Atualizar Runner
```bash
ssh root@154.12.253.40
cd /srv/forgejo-runner
systemctl stop forgejo-runner
# Baixar nova versao
RUNNER_VERSION="6.2.2" # ajustar versao
curl -sL -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
chmod +x forgejo-runner
systemctl start forgejo-runner
```
### Re-registrar Runner
Se o runner perder a conexao:
```bash
ssh root@154.12.253.40
cd /srv/forgejo-runner
# Gerar novo token no Forgejo
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
/usr/local/bin/gitea --config /data/gitea/conf/app.ini actions generate-runner-token
# Re-registrar
systemctl stop forgejo-runner
rm .runner
./forgejo-runner register \
--instance https://git.esdrasrenan.com.br \
--token "NOVO_TOKEN" \
--name "vps-runner" \
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
--no-interactive
systemctl start forgejo-runner
```
### Backup
```bash
# Backup do volume do Forgejo
docker run --rm -v forgejo_forgejo_data:/data -v /backup:/backup alpine \
tar czf /backup/forgejo-backup-$(date +%Y%m%d).tar.gz /data
```
## Troubleshooting
### Runner nao aparece online
```bash
# Verificar status
systemctl status forgejo-runner
journalctl -u forgejo-runner --no-pager -n 50
# Verificar conectividade
curl -s https://git.esdrasrenan.com.br/api/healthz
# Se o runner mostrar erro "404 Not Found" apos reinicio do Forgejo:
systemctl restart forgejo-runner
```
### Workflow nao dispara apos push
1. Verificar se o arquivo esta em `.forgejo/workflows/`
2. Verificar se Actions esta habilitado no repositorio (Settings > Actions)
3. Verificar se o runner esta online (Settings > Actions > Runners)
4. **Regenerar hooks do repositorio:**
```bash
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
/usr/local/bin/gitea admin regenerate hooks --config /data/gitea/conf/app.ini
```
### Erro de LevelDB Lock (queue nao inicia)
Se o Forgejo mostrar erro `unable to lock level db at /data/gitea/queues/common`:
1. O stack.yml ja usa `FORGEJO__queue__TYPE=channel` para evitar esse problema
2. Se o erro persistir, limpe o diretorio de queues:
```bash
docker exec $(docker ps -q --filter "name=forgejo_forgejo") \
rm -rf /data/gitea/queues/*
docker service update --force forgejo_forgejo
```
### Erro de permissao no deploy
O runner precisa de acesso ao Docker:
```bash
# Verificar grupo docker
groups runner
# Adicionar se necessario
usermod -aG docker runner
systemctl restart forgejo-runner
```
## Referencias
- [Forgejo Documentation](https://forgejo.org/docs/)
- [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/)
- [Forgejo Runner](https://code.forgejo.org/forgejo/runner)

View file

@ -1,166 +0,0 @@
# Desenvolvimento Local
Guia para rodar o projeto localmente conectando aos dados de producao.
## Pre-requisitos
- [Bun](https://bun.sh/) 1.3+
- [Docker](https://www.docker.com/) (para PostgreSQL)
- Node.js 20+ (opcional, usado pelo tsx)
## 1. Subir o PostgreSQL
O sistema usa PostgreSQL para autenticacao (Better Auth). Os dados de tickets ficam no Convex.
```bash
docker run -d \
--name postgres-chamados \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:18
```
Para verificar se esta rodando:
```bash
docker ps | grep postgres-chamados
```
Para parar/iniciar posteriormente:
```bash
docker stop postgres-chamados
docker start postgres-chamados
```
## 2. Configurar variaveis de ambiente
O arquivo `.env.local` ja vem configurado para desenvolvimento local apontando para o Convex de producao:
```env
NODE_ENV=development
# URLs locais
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
# Convex de producao (dados reais)
NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br
CONVEX_INTERNAL_URL=https://convex.esdrasrenan.com.br
# PostgreSQL local (apenas autenticacao)
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
```
## 3. Instalar dependencias
```bash
bun install
```
## 4. Gerar cliente Prisma e aplicar schema
```bash
bun run prisma:generate
bunx prisma db push
```
## 5. Criar usuarios de desenvolvimento
O seed cria usuarios locais para autenticacao:
```bash
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
### Credenciais padrao
| Usuario | Email | Senha | Role |
|---------------|----------------------|------------|-------|
| Administrador | `admin@sistema.dev` | `admin123` | admin |
| Agentes | `*@rever.com.br` | `agent123` | agent |
## 6. Iniciar o servidor de desenvolvimento
```bash
bun run dev:bun
```
Acesse: http://localhost:3000
## Arquitetura Local vs Producao
```
┌─────────────────────────────────────────────────────────────┐
│ DESENVOLVIMENTO LOCAL │
├─────────────────────────────────────────────────────────────┤
│ │
│ localhost:3000 (Next.js) │
│ │ │
│ ├──► PostgreSQL local (porta 5432) │
│ │ └── Autenticacao (Better Auth) │
│ │ └── Usuarios, sessoes, contas │
│ │ │
│ └──► convex.esdrasrenan.com.br (remoto) │
│ └── Dados de producao │
│ └── Tickets, empresas, filas, etc. │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Comandos uteis
| Comando | Descricao |
|---------|-----------|
| `bun run dev:bun` | Inicia servidor de desenvolvimento com Turbopack |
| `bun run build:bun` | Build de producao |
| `bun run lint` | Verificar codigo com ESLint |
| `bun test` | Rodar testes |
| `bunx prisma studio` | Interface visual do banco de dados |
## Solucao de problemas
### Erro de conexao com PostgreSQL
```
Error: P1001: Can't reach database server at localhost:5432
```
**Solucao:** Verifique se o container Docker esta rodando:
```bash
docker start postgres-chamados
```
### Erro de migracao (tipo DATETIME)
Se aparecer erro sobre tipo `DATETIME` ao rodar migrations, use `db push` em vez de `migrate`:
```bash
bunx prisma db push --accept-data-loss
```
### Usuario nao consegue logar
Os usuarios de autenticacao ficam no PostgreSQL local, nao no Convex. Rode o seed novamente:
```bash
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
### Limpar banco e recriar
```bash
docker stop postgres-chamados
docker rm postgres-chamados
docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
bunx prisma db push
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
```
## Proximos passos
- Para deploy em producao, consulte `docs/OPERACAO-PRODUCAO.md`
- Para configuracao de SMTP, consulte `docs/SMTP.md`
- Para testes automatizados, consulte `docs/testes-vitest.md`

View file

@ -3,7 +3,6 @@
Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação.
## Visão Geral
- **Desenvolvimento local**: `docs/LOCAL-DEV.md` (setup rapido para rodar localmente)
- Operações (produção): `docs/operations.md`
- Guia de desenvolvimento: `docs/DEV.md`
- Desktop (Tauri):

View file

@ -18,7 +18,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
- Export/backup local de tickets: endpoint `POST /api/admin/tickets/archive-local` (staff) grava tickets resolvidos mais antigos que N dias em JSONL dentro de `ARCHIVE_DIR` (padrão `./archives`). Usa `exportResolvedTicketsToDisk` com segredo interno (`INTERNAL_HEALTH_TOKEN`/`REPORTS_CRON_SECRET`).
## Como acessar tickets antigos sem perda
- Base quente: Prisma (PostgreSQL) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
- Base quente: Prisma (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
- Se um dia for preciso offload (ex.: >50k tickets):
- Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat).
- Gravar um marcador de offload no DB quente (ex.: `ticket_archived_at`, `archive_key`).
@ -33,7 +33,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
## Checks operacionais sugeridos (manuais)
- Tamanho do banco do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "ls -lh /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3"`
- Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"`
- Alvos: <100-200 MB para o SQLite do Convex e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
- Alvos: <100-200 MB para o SQLite e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
## Estado atual e proximos passos
- Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes.

View file

@ -1,252 +0,0 @@
# Setup em Novo Computador
Guia rapido para configurar o ambiente de desenvolvimento em uma nova maquina.
## Pre-requisitos
- **Git** instalado
- **Bun** 1.3+ ([bun.sh](https://bun.sh))
- **Docker** (para PostgreSQL local)
- **Node.js** 20+ (opcional, para algumas ferramentas)
### Instalar Bun (se ainda nao tiver)
```bash
# Linux/macOS/WSL
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
## Configurar Autenticacao (Repositorio Privado)
Se o repositorio for privado, configure autenticacao SSH antes de clonar.
### Opcao 1: SSH Key (Recomendado)
```bash
# 1. Gerar chave SSH (se nao tiver)
ssh-keygen -t ed25519 -C "seu-email@exemplo.com"
# Pressione Enter para aceitar o local padrao
# Defina uma senha ou deixe em branco
# 2. Copiar a chave publica
# Linux/macOS/WSL:
cat ~/.ssh/id_ed25519.pub
# Windows (PowerShell):
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub
# Windows (CMD):
type %USERPROFILE%\.ssh\id_ed25519.pub
```
**Adicionar a chave nos servicos:**
- **GitHub:** Settings > SSH and GPG keys > New SSH key
- **Forgejo:** Settings > SSH / GPG Keys > Add Key
### Opcao 2: Personal Access Token (PAT)
1. **GitHub:** Settings > Developer settings > Personal access tokens > Tokens (classic)
2. Gerar token com permissao `repo`
3. Usar o token como senha quando o git pedir
Para salvar o token (nao precisar digitar toda vez):
```bash
git config --global credential.helper store
# Proximo push/pull vai pedir usuario e token, e salvar
```
## Setup Rapido
### 1. Clonar o repositorio
**Repositorio publico (HTTPS):**
```bash
git clone https://github.com/esdrasrenan/sistema-de-chamados.git
cd sistema-de-chamados
```
**Repositorio privado (SSH):**
```bash
git clone git@github.com:esdrasrenan/sistema-de-chamados.git
cd sistema-de-chamados
```
Ou se ja tiver o repositorio:
```bash
cd sistema-de-chamados
git pull origin main
```
### 2. Configurar remotes (para CI/CD)
**Repositorio publico (HTTPS):**
```bash
git remote add forgejo https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git
```
**Repositorio privado (SSH):**
```bash
# Mudar origin para SSH (se clonou via HTTPS)
git remote set-url origin git@github.com:esdrasrenan/sistema-de-chamados.git
# Adicionar forgejo via SSH (porta 2222)
git remote add forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git
```
**Verificar remotes:**
```bash
git remote -v
# Deve mostrar (exemplo com SSH):
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (fetch)
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (push)
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (fetch)
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (push)
```
### 3. Instalar dependencias
```bash
bun install
```
### 4. Configurar banco de dados
```bash
# Subir PostgreSQL via Docker
docker run -d \
--name postgres-dev \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:18
# Criar arquivo .env
cp .env.example .env
```
Edite o `.env` e configure:
```env
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
BETTER_AUTH_SECRET=sua-chave-secreta-aqui
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
```
### 5. Inicializar o banco
```bash
# Gerar cliente Prisma
bun run prisma:generate
# Criar tabelas no banco
bunx prisma db push
# Popular dados iniciais
bun run auth:seed
```
### 6. Rodar o projeto
```bash
bun run dev:bun
```
Acesse: http://localhost:3000
**Credenciais padrao:** `admin@sistema.dev` / `admin123`
## Comandos Uteis
| Comando | Descricao |
|---------|-----------|
| `bun run dev:bun` | Iniciar servidor de desenvolvimento |
| `bun run build:bun` | Build de producao |
| `bun run lint` | Verificar codigo (ESLint) |
| `bun test` | Rodar testes |
| `bun run prisma:generate` | Gerar cliente Prisma |
| `bunx prisma studio` | Interface visual do banco |
## Fluxo de Trabalho com Git
### Push para ambos os remotes (recomendado)
```bash
# Fazer alteracoes
git add .
git commit -m "sua mensagem"
# Push para GitHub (backup) e Forgejo (CI/CD)
git push origin main && git push forgejo main
```
### Configurar alias para push duplo (opcional)
```bash
# Criar alias
git config alias.push-all '!git push origin main && git push forgejo main'
# Usar
git push-all
```
## Troubleshooting
### Erro: "bun: command not found"
```bash
# Adicionar Bun ao PATH
export PATH="$HOME/.bun/bin:$PATH"
# Adicionar permanentemente ao ~/.bashrc ou ~/.zshrc
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
### Erro: Prisma "P2021" / tabelas nao existem
```bash
bunx prisma db push
bun run auth:seed
```
### Erro: Lockfile desatualizado
```bash
bun install
```
### PostgreSQL nao conecta
```bash
# Verificar se o container esta rodando
docker ps
# Se nao estiver, iniciar
docker start postgres-dev
# Ou recriar
docker rm -f postgres-dev
docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
```
## Convex (Backend de Tempo Real)
Para desenvolvimento com Convex local:
```bash
# Terminal 1: Convex dev server
bun run convex:dev:bun
# Terminal 2: Next.js
bun run dev:bun
```
## Mais Informacoes
- **Desenvolvimento detalhado:** `docs/DEV.md`
- **Deploy e operacoes:** `docs/OPERATIONS.md`
- **CI/CD Forgejo:** `docs/FORGEJO-CI-CD.md`

View file

@ -15,17 +15,14 @@ Configuracao do servidor de email para envio de notificacoes do sistema.
## Variaveis de Ambiente
Nomes usados pelo sistema (conforme `src/lib/env.ts`):
```bash
SMTP_ADDRESS=smtp.c.inova.com.br
SMTP_HOST=smtp.c.inova.com.br
SMTP_PORT=587
SMTP_TLS=false
SMTP_ENABLE_STARTTLS_AUTO=true
SMTP_USERNAME=envio@rever.com.br
SMTP_PASSWORD=CAAJQm6ZT6AUdhXRTDYu
SMTP_DOMAIN=rever.com.br
MAILER_SENDER_EMAIL=Sistema de Chamados <envio@rever.com.br>
SMTP_SECURE=false
SMTP_USER=envio@rever.com.br
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
SMTP_FROM_NAME=Sistema de Chamados
SMTP_FROM_EMAIL=envio@rever.com.br
```
## Exemplo de Uso (Nodemailer)

View file

@ -1,54 +0,0 @@
# Alteracoes de producao - 2025-12-18
Este documento registra as mudancas aplicadas na VPS para estabilizar o ambiente e padronizar o uso do PostgreSQL 18.
## Resumo
- Migracao do banco principal do sistema para o servico `postgres18`.
- Desativacao do servico `postgres` (pg16) no Swarm.
- Convex backend fixado na tag `ghcr.io/get-convex/convex-backend:6690a911bced1e5e516eafc0409a7239fb6541bb`.
- `CONVEX_INTERNAL_URL` ajustado para o endpoint publico, evitando falhas de DNS interno (`ENOTFOUND sistema_convex_backend`).
- Tratamento explicito para tokens revogados/expirados/invalidos nas rotas `/api/machines/*` e chat.
- Limpeza de documento legado no Convex (`liveChatSessions` id `pd71bvfbxx7th3npdj519hcf3s7xbe2j`).
## Backups gerados
- `/root/pg-backups/sistema_chamados_pg16_20251218215925.dump`
- `/root/pg-backups/sistema_chamados_pg18_20251218215925.dump`
- Convex: `/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717`
- Observacao: foi gerado um arquivo extra `db.sqlite3.backup-` (sem timestamp) por comando incorreto.
## Procedimento (principais comandos)
```
# 1) Backup dos bancos
docker exec -u postgres <pg16> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg16_20251218215925.dump
docker exec -u postgres <pg18> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg18_20251218215925.dump
# 2) Parar o web durante a migracao
docker service scale sistema_web=0
# 3) Restaurar dump do pg16 no pg18
docker exec -u postgres <pg18> psql -c "DROP DATABASE IF EXISTS sistema_chamados;"
docker exec -u postgres <pg18> psql -c "CREATE DATABASE sistema_chamados OWNER sistema;"
docker cp /root/pg-backups/sistema_chamados_pg16_20251218215925.dump <pg18>:/tmp/sistema_chamados_restore.dump
docker exec -u postgres <pg18> pg_restore -d sistema_chamados -Fc /tmp/sistema_chamados_restore.dump
# 4) Atualizar stack (com variaveis exportadas)
set -a; . /srv/apps/sistema/.env; set +a
docker stack deploy --with-registry-auth -c /srv/apps/sistema/stack.yml sistema
# 5) Desativar pg16
docker service scale postgres=0
```
## Ajustes em stack.yml
- `DATABASE_URL` apontando para `postgres18:5432`.
- `CONVEX_INTERNAL_URL` apontando para `https://convex.esdrasrenan.com.br`.
- Imagem do Convex ajustada para a tag acima.
## Resultado
- `sistema_web` voltou com 2 replicas saudaveis.
- `sistema_convex_backend` rodando na tag informada.
- `postgres` (pg16) desativado no Swarm.
- Healthcheck OK: `GET /api/health` e `GET /version`.
## Observacoes operacionais
- O deploy do stack precisa de variaveis exportadas do `.env`. Sem isso, `NEXT_PUBLIC_*` fica vazio e o `POSTGRES_PASSWORD` nao e propagado, causando `P1000` no Prisma.

View file

@ -1,32 +0,0 @@
# Alteracoes de producao - 2025-12-19
Registro das correcoes aplicadas na VPS para reduzir erros em logs e estabilizar certificados e Convex.
## Traefik / TLS
- ACME alterado de HTTP-01 para TLS-ALPN no servico `traefik_traefik`.
- Reinicio do servico Traefik para aplicar a nova configuracao.
## Certificados ACME
- Remocao de certificados obsoletos no `acme.json`:
- `pgadmin.rever.com.br`
- `supa.rever.com.br`
- `compressor.esdrasrenan.com.br`
- Backups gerados:
- `/var/lib/docker/volumes/certificados/_data/acme.json.backup-20251219011425`
- `/var/lib/docker/volumes/certificados/_data/acme.json.backup-` (gerado sem timestamp por comando anterior)
## Convex
- Adicionado `convex_proxy` (tinyproxy) e configurado `--convex-http-proxy` para remover warning de proxy ausente.
- Adicionado `convex_block` (http-echo) para bloquear `POST /api/*` com `Content-Type` nao JSON (415).
- Prioridades de roteamento ajustadas:
- `sistema_convex_api_json` (priority 100)
- `sistema_convex_api_block` (priority 50)
- `sistema_convex` (priority 1)
- `RUST_LOG` ajustado para `info,common::errors=error` a fim de reduzir ruido de warnings nao criticos.
## Stack / Rede
- Criada rede `convex_internal` (overlay, internal) para trafego interno do Convex com o proxy.
- Arquivo atualizado: `/srv/apps/sistema/stack.yml` (stack `sistema`).
## Observacoes
- A alteracao do ACME foi feita via `docker service update --args` no Traefik (nao ha stack file versionado).

View file

@ -112,39 +112,7 @@ Critérios de sucesso:
---
## 6. Registro de alterações manuais
### 2025-12-18 — liveChatSessions com versão legada (shape_inference)
Motivo: logs do Convex mostravam `shape_inference` recorrente apontando para o documento
`pd71bvfbxx7th3npdj519hcf3s7xbe2j` (sessão de chat antiga com status `ACTIVE` em versão histórica).
Comandos executados:
```bash
# 1) Parar Convex
docker service scale sistema_convex_backend=0
# 2) Backup
cp /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3 \
/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717
# 3) Remover versões antigas do documento (mantendo a mais recente)
docker run --rm -v sistema_convex_data:/convex/data nouchka/sqlite3 /convex/data/db.sqlite3 \
"DELETE FROM documents \
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j' \
AND ts < (SELECT MAX(ts) FROM documents \
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j');"
# 4) Subir Convex
docker service scale sistema_convex_backend=1
```
Resultado: versões antigas do documento foram removidas e os erros de `shape_inference` pararam após o restart.
---
## 7. Referências rápidas
## 6. Referências rápidas
- Volume Convex: `sistema_convex_data`
- Banco: `/convex/data/db.sqlite3`
@ -154,4 +122,4 @@ Resultado: versões antigas do documento foram removidas e os erros de `shape_in
---
Última revisão: **18/12/2025** — limpeza da versão legada de `liveChatSessions` (`pd71bvfbxx7th3npdj519hcf3s7xbe2j`) e restart do Convex.
Última revisão: **18/11/2025** — sanado por remoção dos registros incompatíveis e rerun bem-sucedido do export `gg20vw5b479d9a2jprjpe3pxg57vk9wa`.

View file

@ -1,51 +0,0 @@
# Diagnostico — Chat do desktop (2025-12-19)
## Contexto
Relato de instabilidade no chat do desktop (Raven): mensagens enviadas pela web nao chegavam ao app, e com multiplas sessoes a janela travava/nao abria.
## Evidencias coletadas
- `tickets:getById` confirmou ticket #41048 vinculado a maquina `jn7fc2d5dd8f1qw340ya092k6d7xjrps`, chat habilitado e maquina online.
- `liveChat:getTicketSession` nao tinha sessao ativa antes do teste.
- Teste ponta a ponta via Convex:
- `liveChat:startSession` + `tickets:postChatMessage` criaram sessao e mensagem.
- `liveChat:checkMachineUpdates` retornou `hasActiveSessions=true` e `unreadCount=1`.
- `liveChat:listMachineMessages` retornou a nova mensagem.
- `POST /api/machines/chat/poll` confirmou o mesmo unread.
- Traefik (VPS): nao ha chamadas do desktop para `/api/machines/chat/*` nem `raven-chat/1.0` nas ultimas horas.
- Logs locais do desktop:
- `raven-agent.log` sem entradas `[CHAT DEBUG]`.
- `app.log` sem `chat:started`.
- Com duas sessoes ativas, o log parou em:
- `[CMD] open_chat_window called...`
- `[WINDOW] ... build() inicio`
- sem `build() OK` / `open_chat_window result`, indicando travamento na criacao da janela quando chamada via comando.
## Causa raiz
O desktop nao estava iniciando o runtime de chat.
Em `apps/desktop/src/main.tsx`, o `invoke("start_chat_polling", ...)` enviava `base_url` e `convex_url` em snake_case. No Tauri v2, o mapeamento esperado e camelCase (`baseUrl`, `convexUrl`). Com isso, o comando falha na desserializacao dos args e o chat nao inicia (sem polling/WebSocket), resultando em nenhuma mensagem chegando ao app.
Em cenarios com multiplas sessoes, a abertura do segundo chat via hub usa o comando `open_chat_window` (JS). Esse comando era sincrono e rodava no thread principal; ao criar uma nova janela (`WebviewWindowBuilder::build`), a execucao travava e a janela nao concluia o build, congelando o chat no desktop.
## Correcoes aplicadas
- Ajustado `invoke("start_chat_polling")` para usar `baseUrl` e `convexUrl` (camelCase).
- Tornado `open_chat_window` e `open_hub_window` assíncronos, executando em `spawn_blocking` para evitar bloqueio do thread principal ao criar novas janelas de chat.
- Quando o chat esta aberto e no fim da conversa, o desktop marca automaticamente mensagens como lidas (evita badge preso).
- Ao abrir um chat (foco), outras janelas de chat sao ocultadas e o hub e escondido para evitar sobreposicao.
- Ao minimizar um chat, outras janelas de chat abertas sao ocultadas automaticamente.
## Arquivos alterados
- `apps/desktop/src/main.tsx`
- `apps/desktop/src-tauri/src/lib.rs`
- `apps/desktop/src-tauri/src/chat.rs`
- `apps/desktop/src/chat/ChatWidget.tsx`
## Testes recomendados
- `bun run lint`
- `bun test`
- `bun run build:bun`
## Validacao operativa (pos-build)
1. Abrir o Raven com a maquina online.
2. Enviar mensagem no ticket #41048.
3. Confirmar em `raven-agent.log` a sequencia `[CHAT DEBUG] Iniciando sistema de chat` e eventos `chat:started` em `app.log`.
4. Verificar no Traefik chamadas `/api/machines/chat/poll` ou conexoes WS do Convex com origin `http://tauri.localhost`.

View file

@ -14,18 +14,6 @@ export type TicketCardData = {
assigneeName?: string | null
}
export type TicketCardProps = {
ticketNumber: string
ticketTitle: string
status?: string | null
priority?: string | null
category?: string | null
subcategory?: string | null
companyName?: string | null
requesterName?: string | null
assigneeName?: string | null
}
function badge(label: string, bg: string, color: string) {
return (
<span
@ -88,8 +76,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
)
}
/** @deprecated Use TicketCard with props instead */
export function TicketCardLegacy({ ticket }: { ticket: TicketCardData }) {
export function TicketCard({ ticket }: { ticket: TicketCardData }) {
return (
<Section
style={{
@ -113,90 +100,3 @@ export function TicketCardLegacy({ ticket }: { ticket: TicketCardData }) {
</Section>
)
}
export function TicketCard(props: TicketCardProps) {
const { ticketNumber, ticketTitle, status, priority, category, subcategory, companyName, requesterName, assigneeName } = props
const categoryLabel = category && subcategory ? `${category} / ${subcategory}` : category ?? subcategory ?? null
return (
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #f1f5f9" }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Chamado #{ticketNumber}
</Text>
<Text style={{ margin: "4px 0 0 0", fontSize: "16px", fontWeight: 700, color: EMAIL_COLORS.textPrimary }}>
{ticketTitle}
</Text>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
{status ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Status
</td>
<td style={{ paddingBottom: "10px" }}>{statusBadge(status)}</td>
</tr>
) : null}
{priority ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Prioridade
</td>
<td style={{ paddingBottom: "10px" }}>{priorityBadge(priority)}</td>
</tr>
) : null}
{categoryLabel ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Categoria
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{categoryLabel}</td>
</tr>
) : null}
{companyName ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Empresa
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{companyName}</td>
</tr>
) : null}
{requesterName ? (
<tr>
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Solicitante
</td>
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{requesterName}</td>
</tr>
) : null}
{assigneeName ? (
<tr>
<td style={{ width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
Responsavel
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{assigneeName}</td>
</tr>
) : null}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
)
}

View file

@ -3,7 +3,7 @@ import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
import { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card"
import { TicketCard, type TicketCardData } from "./_components/ticket-card"
import { normalizeTextToParagraphs } from "./_components/utils"
export type AutomationEmailProps = {
@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) {
</Text>
)}
<TicketCardLegacy ticket={props.ticket} />
<TicketCard ticket={props.ticket} />
<Section style={{ marginTop: "18px" }}>
<Button

View file

@ -1,132 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type InviteEmailProps = {
inviterName: string
roleName: string
companyName?: string | null
inviteUrl: string
}
export default function InviteEmail(props: InviteEmailProps) {
const { inviterName, roleName, companyName, inviteUrl } = props
return (
<RavenEmailLayout title="Convite para o Sistema de Chamados" preview={`${inviterName} convidou voce para acessar o Sistema de Chamados Raven`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: EMAIL_COLORS.primary,
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
}}
>
&#127881;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Voce foi convidado!
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
<strong>{inviterName}</strong> convidou voce para acessar o Sistema de Chamados Raven.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
Funcao
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontWeight: 600 }}>
{roleName}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{companyName ? (
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
Empresa
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{companyName}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
) : null}
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={inviteUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Aceitar convite
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Se o botao nao funcionar, copie e cole esta URL no navegador:
<br />
<a href={inviteUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
{inviteUrl}
</a>
</Text>
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca.
</Text>
</RavenEmailLayout>
)
}
InviteEmail.PreviewProps = {
inviterName: "Renan Oliveira",
roleName: "Agente",
companyName: "Paulicon",
inviteUrl: "https://raven.rever.com.br/invite/abc123def456",
} satisfies InviteEmailProps

View file

@ -1,150 +0,0 @@
import * as React from "react"
import { Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type NewLoginEmailProps = {
loginAt: string
ipAddress: string
userAgent: string
location?: string | null
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(date)
} catch {
return dateStr
}
}
export default function NewLoginEmail(props: NewLoginEmailProps) {
const { loginAt, ipAddress, userAgent, location } = props
return (
<RavenEmailLayout title="Novo acesso detectado" preview="Detectamos um novo acesso a sua conta">
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#128274;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Novo acesso detectado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Data/Hora
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{formatDate(loginAt)}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Endereco IP
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontFamily: "monospace" }}>
{ipAddress}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{location ? (
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Localizacao
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
{location}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
) : null}
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
Dispositivo
</td>
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "13px" }}>
{userAgent}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: "0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente.
</Text>
</RavenEmailLayout>
)
}
NewLoginEmail.PreviewProps = {
loginAt: new Date().toISOString(),
ipAddress: "192.168.1.100",
userAgent: "Chrome 120.0 / Windows 11",
location: "Sao Paulo, SP, Brasil",
} satisfies NewLoginEmailProps

View file

@ -1,81 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type PasswordResetEmailProps = {
resetUrl: string
expiresIn?: string
}
export default function PasswordResetEmail(props: PasswordResetEmailProps) {
const { resetUrl, expiresIn = "1 hora" } = props
return (
<RavenEmailLayout title="Redefinicao de senha" preview="Voce solicitou a redefinicao de sua senha">
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#128274;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Redefinir senha
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha.
</Text>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={resetUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Redefinir senha
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Se o botao nao funcionar, copie e cole esta URL no navegador:
<br />
<a href={resetUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
{resetUrl}
</a>
</Text>
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca.
</Text>
</RavenEmailLayout>
)
}
PasswordResetEmail.PreviewProps = {
resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456",
expiresIn: "1 hora",
} satisfies PasswordResetEmailProps

View file

@ -1,151 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type SlaBreachedEmailProps = {
ticketNumber: string
ticketTitle: string
breachedAt: string
ticketUrl: string
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(date)
} catch {
return dateStr
}
}
export default function SlaBreachedEmail(props: SlaBreachedEmailProps) {
const { ticketNumber, ticketTitle, breachedAt, ticketUrl } = props
return (
<RavenEmailLayout title="SLA estourado" preview={`Chamado #${ticketNumber} estourou o SLA`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fee2e2",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #ef4444",
}}
>
&#128680;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
SLA estourado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata.
</Text>
<Section
style={{
backgroundColor: "#fef2f2",
borderRadius: "12px",
border: "1px solid #fca5a5",
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Chamado
</td>
<td style={{ color: "#7f1d1d", fontSize: "14px", fontWeight: 600 }}>
#{ticketNumber}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Titulo
</td>
<td style={{ color: "#7f1d1d", fontSize: "14px" }}>
{ticketTitle}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Estourado em
</td>
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
{formatDate(breachedAt)}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: "#dc2626",
color: "#ffffff",
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: "1px solid #b91c1c",
}}
>
Atender agora
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Este chamado deve ser tratado com prioridade maxima.
</Text>
</RavenEmailLayout>
)
}
SlaBreachedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
breachedAt: new Date().toISOString(),
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies SlaBreachedEmailProps

View file

@ -1,139 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type SlaWarningEmailProps = {
ticketNumber: string
ticketTitle: string
timeRemaining: string
ticketUrl: string
}
export default function SlaWarningEmail(props: SlaWarningEmailProps) {
const { ticketNumber, ticketTitle, timeRemaining, ticketUrl } = props
return (
<RavenEmailLayout title="Alerta de SLA" preview={`Chamado #${ticketNumber} esta proximo de estourar o SLA`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#fef3c7",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #f59e0b",
}}
>
&#9888;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Alerta de SLA
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado abaixo esta proximo de estourar o tempo de atendimento acordado.
</Text>
<Section
style={{
backgroundColor: "#fffbeb",
borderRadius: "12px",
border: "1px solid #fcd34d",
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Chamado
</td>
<td style={{ color: "#78350f", fontSize: "14px", fontWeight: 600 }}>
#{ticketNumber}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Titulo
</td>
<td style={{ color: "#78350f", fontSize: "14px" }}>
{ticketTitle}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
Tempo restante
</td>
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
{timeRemaining}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Acesse o sistema para mais detalhes e acompanhe o status do chamado.
</Text>
</RavenEmailLayout>
)
}
SlaWarningEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
timeRemaining: "45 minutos",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies SlaWarningEmailProps

View file

@ -1,82 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketAssignedEmailProps = TicketCardProps & {
ticketUrl: string
assigneeName: string
}
export default function TicketAssignedEmail(props: TicketAssignedEmailProps) {
const { ticketUrl, assigneeName, ...ticketProps } = props
return (
<RavenEmailLayout title="Chamado atribuido" preview={`Chamado #${ticketProps.ticketNumber} foi atribuido a ${assigneeName}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128100;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado atribuido
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O chamado foi atribuido a <strong>{assigneeName}</strong>.
</Text>
<TicketCard {...ticketProps} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketAssignedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
assigneeName: "Weslei Magalhaes",
} satisfies TicketAssignedEmailProps

View file

@ -1,113 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketCommentEmailProps = {
ticketNumber: string
ticketTitle: string
commenterName: string
commentPreview: string
ticketUrl: string
}
export default function TicketCommentEmail(props: TicketCommentEmailProps) {
const { ticketNumber, ticketTitle, commenterName, commentPreview, ticketUrl } = props
return (
<RavenEmailLayout title="Novo comentario" preview={`${commenterName} comentou no chamado #${ticketNumber}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128172;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Novo comentario
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
<strong>{commenterName}</strong> comentou no chamado <strong>#{ticketNumber}</strong>.
</Text>
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
}}
>
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Chamado #{ticketNumber}
</Text>
<Text style={{ margin: "4px 0 0 0", fontSize: "14px", fontWeight: 600, color: EMAIL_COLORS.textPrimary }}>
{ticketTitle}
</Text>
</td>
</tr>
<tr>
<td style={{ padding: "16px 20px" }}>
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Comentario
</Text>
<Text style={{ margin: "8px 0 0 0", fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
{commentPreview}
</Text>
</td>
</tr>
</tbody>
</table>
</Section>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver e responder
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Clique no botao acima para ver o comentario completo e responder.
</Text>
</RavenEmailLayout>
)
}
TicketCommentEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
commenterName: "Weslei Magalhaes",
commentPreview: "Ola! Ja verificamos o problema e parece ser relacionado ao driver da placa de video. Vou precisar de acesso remoto para fazer a correcao...",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies TicketCommentEmailProps

View file

@ -1,80 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketCreatedEmailProps = TicketCardProps & {
ticketUrl: string
}
export default function TicketCreatedEmail(props: TicketCreatedEmailProps) {
const { ticketUrl, ...ticketProps } = props
return (
<RavenEmailLayout title="Novo chamado criado" preview={`Chamado #${ticketProps.ticketNumber} foi criado com sucesso`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#ecfdf5",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #10b981",
}}
>
&#9989;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado criado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe.
</Text>
<TicketCard {...ticketProps} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Acompanhar chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketCreatedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "PENDING",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
} satisfies TicketCreatedEmailProps

View file

@ -1,121 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
export type TicketResolvedEmailProps = TicketCardProps & {
ticketUrl: string
ratingUrl?: string | null
resolution?: string | null
}
export default function TicketResolvedEmail(props: TicketResolvedEmailProps) {
const { ticketUrl, ratingUrl, resolution, ...ticketProps } = props
return (
<RavenEmailLayout title="Chamado resolvido" preview={`Chamado #${ticketProps.ticketNumber} foi resolvido`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#ecfdf5",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #10b981",
}}
>
&#127881;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Chamado resolvido
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
Seu chamado foi marcado como resolvido. Confira os detalhes abaixo.
</Text>
<TicketCard {...ticketProps} status="RESOLVED" />
{resolution ? (
<Section
style={{
backgroundColor: "#f8fafc",
borderRadius: "12px",
border: `1px solid ${EMAIL_COLORS.border}`,
margin: "24px 0",
padding: "16px 20px",
}}
>
<Text style={{ margin: "0 0 8px 0", fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
Resolucao
</Text>
<Text style={{ margin: 0, fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
{resolution}
</Text>
</Section>
) : null}
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver detalhes
</Button>
{ratingUrl ? (
<Button
href={ratingUrl}
style={{
display: "inline-block",
backgroundColor: "#0f172a",
color: "#f8fafc",
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
marginLeft: "12px",
}}
>
Avaliar atendimento
</Button>
) : null}
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar.
</Text>
</RavenEmailLayout>
)
}
TicketResolvedEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "RESOLVED",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
ratingUrl: "https://raven.rever.com.br/rate/abc123",
resolution: "Problema resolvido apos atualizacao do driver da placa de video e reinicializacao do sistema.",
} satisfies TicketResolvedEmailProps

View file

@ -1,85 +0,0 @@
import * as React from "react"
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
import { RavenEmailLayout } from "./_components/layout"
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
import { EMAIL_COLORS } from "./_components/tokens"
import { formatStatus } from "./_components/utils"
export type TicketStatusEmailProps = TicketCardProps & {
ticketUrl: string
previousStatus: string
newStatus: string
}
export default function TicketStatusEmail(props: TicketStatusEmailProps) {
const { ticketUrl, previousStatus, newStatus, ...ticketProps } = props
return (
<RavenEmailLayout title="Status atualizado" preview={`Chamado #${ticketProps.ticketNumber} mudou de ${formatStatus(previousStatus)} para ${formatStatus(newStatus)}`}>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<div
style={{
display: "inline-block",
width: "64px",
height: "64px",
backgroundColor: "#e0f2fe",
borderRadius: "50%",
lineHeight: "64px",
fontSize: "28px",
border: "1px solid #0ea5e9",
}}
>
&#128260;
</div>
</Section>
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
Status atualizado
</Heading>
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
O status do seu chamado foi alterado de <strong>{formatStatus(previousStatus)}</strong> para <strong>{formatStatus(newStatus)}</strong>.
</Text>
<TicketCard {...ticketProps} status={newStatus} />
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={ticketUrl}
style={{
display: "inline-block",
backgroundColor: EMAIL_COLORS.primary,
color: EMAIL_COLORS.primaryForeground,
textDecoration: "none",
borderRadius: "12px",
padding: "14px 24px",
fontWeight: 800,
fontSize: "14px",
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
}}
>
Ver chamado
</Button>
</Section>
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
Voce recebera atualizacoes por e-mail quando houver novidades.
</Text>
</RavenEmailLayout>
)
}
TicketStatusEmail.PreviewProps = {
ticketNumber: "41025",
ticketTitle: "Computador nao liga apos atualizacao",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
category: "Hardware",
subcategory: "Desktop",
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
previousStatus: "PENDING",
newStatus: "AWAITING_ATTENDANCE",
} satisfies TicketStatusEmailProps

View file

@ -15,7 +15,6 @@ const eslintConfig = [
"referência/**",
"next-env.d.ts",
"convex/_generated/**",
"apps/desktop/src/convex/_generated/**",
],
},
{

View file

@ -1,113 +0,0 @@
#!/bin/bash
# Script para configurar o Forgejo Runner
# Execute na VPS apos o Forgejo estar rodando
set -e
FORGEJO_URL="${FORGEJO_URL:-https://git.esdrasrenan.com.br}"
RUNNER_NAME="${RUNNER_NAME:-vps-runner}"
RUNNER_DIR="/srv/forgejo-runner"
CONFIG_FILE="$RUNNER_DIR/config.yml"
echo "=== Configuracao do Forgejo Runner ==="
echo ""
echo "1. Acesse o Forgejo: $FORGEJO_URL"
echo "2. Va em: Site Administration > Actions > Runners"
echo "3. Clique em 'Create new Runner'"
echo "4. Copie o token de registro"
echo ""
read -p "Cole o token de registro aqui: " REGISTRATION_TOKEN
if [ -z "$REGISTRATION_TOKEN" ]; then
echo "ERRO: Token nao pode ser vazio"
exit 1
fi
# Criar diretorio do runner
mkdir -p "$RUNNER_DIR"
cd "$RUNNER_DIR"
# Baixar o runner se nao existir
if [ ! -f "./forgejo-runner" ]; then
echo "Baixando Forgejo Runner..."
RUNNER_VERSION="6.2.2"
curl -L -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
chmod +x forgejo-runner
fi
# Registrar o runner
echo "Registrando runner..."
./forgejo-runner register \
--instance "$FORGEJO_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$RUNNER_NAME" \
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
--no-interactive
# Criar config.yml customizado
cat > "$CONFIG_FILE" << 'EOF'
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "ubuntu-latest:docker://node:20-bookworm"
- "self-hosted:host"
- "linux:host"
- "vps:host"
cache:
enabled: true
dir: /tmp/forgejo-runner-cache
host: ""
port: 0
external_server: ""
container:
network: "host"
privileged: false
options: ""
workdir_parent: /tmp/forgejo-runner-workdir
valid_volumes:
- /var/run/docker.sock
- /home/runner/apps
- /srv/apps
- /tmp
docker_host: ""
force_pull: false
host:
workdir_parent: /tmp/forgejo-runner-workdir
EOF
echo ""
echo "=== Runner registrado com sucesso! ==="
echo ""
echo "Para iniciar o runner como servico systemd, execute:"
echo ""
echo "sudo tee /etc/systemd/system/forgejo-runner.service << 'SYSTEMD'
[Unit]
Description=Forgejo Runner
After=docker.service network.target
[Service]
Type=simple
User=runner
WorkingDirectory=$RUNNER_DIR
ExecStart=$RUNNER_DIR/forgejo-runner daemon --config $CONFIG_FILE
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SYSTEMD"
echo ""
echo "sudo systemctl daemon-reload"
echo "sudo systemctl enable forgejo-runner"
echo "sudo systemctl start forgejo-runner"

View file

@ -1,89 +0,0 @@
version: "3.8"
# Forgejo para CI/CD self-hosted
# Substitui o GitHub Actions sem perder a experiencia visual
# NOTA: O runner roda como servico systemd, nao como container no Swarm
services:
forgejo:
image: codeberg.org/forgejo/forgejo:11
environment:
- USER_UID=1000
- USER_GID=1000
# Configuracoes do Forgejo
- FORGEJO__database__DB_TYPE=sqlite3
- FORGEJO__database__PATH=/data/gitea/forgejo.db
- FORGEJO__server__DOMAIN=git.esdrasrenan.com.br
- FORGEJO__server__ROOT_URL=https://git.esdrasrenan.com.br/
- FORGEJO__server__SSH_DOMAIN=git.esdrasrenan.com.br
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__server__HTTP_PORT=3000
- FORGEJO__server__OFFLINE_MODE=false
# Actions habilitado
- FORGEJO__actions__ENABLED=true
- FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
# Seguranca - INSTALL_LOCK=true apos instalacao inicial
- FORGEJO__security__INSTALL_LOCK=true
- FORGEJO__service__DISABLE_REGISTRATION=true
# Queue - usar channel em vez de leveldb para evitar problemas de lock
- FORGEJO__queue__TYPE=channel
- FORGEJO__queue__DATADIR=queues/
# Logs
- FORGEJO__log__MODE=console
- FORGEJO__log__LEVEL=Info
volumes:
- forgejo_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- traefik_public
- forgejo_internal
ports:
# SSH para git clone via SSH (exposto diretamente)
- "2222:2222"
deploy:
mode: replicated
replicas: 1
update_config:
parallelism: 1
order: start-first
failure_action: rollback
delay: 10s
monitor: 30s
resources:
limits:
memory: "1G"
reservations:
memory: "256M"
restart_policy:
condition: any
delay: 5s
max_attempts: 3
window: 120s
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
# Web UI
- traefik.http.routers.forgejo.rule=Host(`git.esdrasrenan.com.br`)
- traefik.http.routers.forgejo.entrypoints=websecure
- traefik.http.routers.forgejo.tls=true
- traefik.http.routers.forgejo.tls.certresolver=le
- traefik.http.services.forgejo.loadbalancer.server.port=3000
healthcheck:
test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
forgejo_data:
networks:
traefik_public:
external: true
forgejo_internal:
driver: overlay

View file

@ -46,7 +46,6 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",

View file

@ -1,252 +0,0 @@
#!/bin/bash
# Script de setup para ambiente de desenvolvimento
# Uso: ./scripts/setup-dev.sh [--ssh]
#
# Opcoes:
# --ssh Configurar remotes usando SSH (para repositorio privado)
set -e
# Verificar se deve usar SSH
USE_SSH=false
if [ "$1" = "--ssh" ]; then
USE_SSH=true
fi
echo "=== Setup do Ambiente de Desenvolvimento ==="
echo ""
# Cores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Funcao para printar status
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[AVISO]${NC} $1"; }
err() { echo -e "${RED}[ERRO]${NC} $1"; }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
# 1. Verificar pre-requisitos
echo "1. Verificando pre-requisitos..."
# Verificar Bun
if command -v bun &> /dev/null; then
BUN_VERSION=$(bun --version)
ok "Bun instalado: v$BUN_VERSION"
else
err "Bun nao encontrado!"
echo " Instale com: curl -fsSL https://bun.sh/install | bash"
exit 1
fi
# Verificar Docker
if command -v docker &> /dev/null; then
ok "Docker instalado"
else
warn "Docker nao encontrado. Voce precisara configurar o PostgreSQL manualmente."
fi
# Verificar Git
if command -v git &> /dev/null; then
ok "Git instalado"
else
err "Git nao encontrado!"
exit 1
fi
# Verificar SSH key (se usando SSH)
if [ "$USE_SSH" = true ]; then
echo ""
echo "1.1. Verificando chave SSH..."
if [ -f "$HOME/.ssh/id_ed25519.pub" ] || [ -f "$HOME/.ssh/id_rsa.pub" ]; then
ok "Chave SSH encontrada"
echo " Certifique-se de que a chave esta adicionada no GitHub e Forgejo"
else
warn "Chave SSH nao encontrada!"
echo ""
echo " Para criar uma chave SSH:"
echo " ssh-keygen -t ed25519 -C \"seu-email@exemplo.com\""
echo ""
echo " Depois adicione a chave publica em:"
echo " - GitHub: Settings > SSH and GPG keys > New SSH key"
echo " - Forgejo: Settings > SSH / GPG Keys > Add Key"
echo ""
read -p " Deseja continuar mesmo assim? (s/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
exit 1
fi
fi
fi
echo ""
# 2. Configurar remotes do Git
echo "2. Configurando remotes do Git..."
# Verificar se estamos em um repositorio git
if [ ! -d ".git" ]; then
err "Este diretorio nao e um repositorio Git!"
exit 1
fi
# URLs dos remotes
if [ "$USE_SSH" = true ]; then
ORIGIN_URL="git@github.com:esdrasrenan/sistema-de-chamados.git"
FORGEJO_URL="ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git"
info "Usando SSH para os remotes (repositorio privado)"
else
ORIGIN_URL="https://github.com/esdrasrenan/sistema-de-chamados.git"
FORGEJO_URL="https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git"
info "Usando HTTPS para os remotes (repositorio publico)"
fi
# Configurar/atualizar origin
CURRENT_ORIGIN=$(git remote get-url origin 2>/dev/null || echo "")
if [ "$CURRENT_ORIGIN" != "$ORIGIN_URL" ]; then
if [ -n "$CURRENT_ORIGIN" ]; then
git remote set-url origin "$ORIGIN_URL"
ok "Remote 'origin' atualizado para $ORIGIN_URL"
fi
else
ok "Remote 'origin' ja configurado corretamente"
fi
# Verificar/adicionar remote forgejo
if git remote get-url forgejo &> /dev/null; then
CURRENT_FORGEJO=$(git remote get-url forgejo)
if [ "$CURRENT_FORGEJO" != "$FORGEJO_URL" ]; then
git remote set-url forgejo "$FORGEJO_URL"
ok "Remote 'forgejo' atualizado para $FORGEJO_URL"
else
ok "Remote 'forgejo' ja configurado corretamente"
fi
else
git remote add forgejo "$FORGEJO_URL"
ok "Remote 'forgejo' adicionado"
fi
# Mostrar remotes
echo " Remotes configurados:"
git remote -v | sed 's/^/ /'
echo ""
# 3. Instalar dependencias
echo "3. Instalando dependencias..."
bun install
ok "Dependencias instaladas"
echo ""
# 4. Configurar arquivo .env
echo "4. Configurando arquivo .env..."
if [ -f ".env" ]; then
warn "Arquivo .env ja existe. Pulando..."
else
if [ -f ".env.example" ]; then
cp .env.example .env
ok "Arquivo .env criado a partir do .env.example"
warn "IMPORTANTE: Edite o arquivo .env com suas configuracoes!"
else
# Criar .env basico
cat > .env << 'EOF'
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
BETTER_AUTH_SECRET=dev-secret-change-in-production
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210
EOF
ok "Arquivo .env criado com valores padrao para desenvolvimento"
warn "IMPORTANTE: Ajuste as configuracoes conforme necessario!"
fi
fi
echo ""
# 5. Configurar PostgreSQL via Docker
echo "5. Configurando PostgreSQL..."
if command -v docker &> /dev/null; then
if docker ps -a --format '{{.Names}}' | grep -q '^postgres-dev$'; then
# Container existe, verificar se esta rodando
if docker ps --format '{{.Names}}' | grep -q '^postgres-dev$'; then
ok "PostgreSQL ja esta rodando"
else
docker start postgres-dev
ok "PostgreSQL iniciado"
fi
else
# Criar container
docker run -d \
--name postgres-dev \
-p 5432:5432 \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=sistema_chamados \
postgres:16
ok "PostgreSQL criado e iniciado"
echo " Aguardando PostgreSQL inicializar..."
sleep 3
fi
else
warn "Docker nao disponivel. Configure o PostgreSQL manualmente."
echo " DATABASE_URL deve apontar para seu servidor PostgreSQL"
fi
echo ""
# 6. Gerar cliente Prisma
echo "6. Gerando cliente Prisma..."
bun run prisma:generate
ok "Cliente Prisma gerado"
echo ""
# 7. Inicializar banco de dados
echo "7. Inicializando banco de dados..."
# Verificar se o banco esta acessivel
if bunx prisma db push --skip-generate 2>/dev/null; then
ok "Schema do banco atualizado"
# Seed inicial
echo " Populando dados iniciais..."
if bun run auth:seed 2>/dev/null; then
ok "Dados iniciais criados"
else
warn "Seed falhou ou ja foi executado anteriormente"
fi
else
warn "Nao foi possivel conectar ao banco de dados"
echo " Verifique se o PostgreSQL esta rodando e as credenciais no .env"
fi
echo ""
# 8. Configurar alias do Git (opcional)
echo "8. Configurando alias do Git..."
if git config --get alias.push-all &> /dev/null; then
ok "Alias 'push-all' ja configurado"
else
git config alias.push-all '!git push origin main && git push forgejo main'
ok "Alias 'push-all' criado (use: git push-all)"
fi
echo ""
echo "=== Setup Concluido! ==="
echo ""
echo "Proximos passos:"
echo " 1. Verifique/edite o arquivo .env"
echo " 2. Execute: bun run dev:bun"
echo " 3. Acesse: http://localhost:3000"
echo " 4. Login: admin@sistema.dev / admin123"
echo ""
echo "Para fazer deploy:"
echo " git push origin main && git push forgejo main"
echo " ou: git push-all"
echo ""

View file

@ -1,188 +0,0 @@
import * as React from "react"
import dotenv from "dotenv"
import { render } from "@react-email/render"
import { sendSmtpMail } from "@/server/email-smtp"
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
dotenv.config({ path: ".env.local" })
dotenv.config({ path: ".env" })
function getSmtpConfig() {
const host = process.env.SMTP_HOST
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER
const password = process.env.SMTP_PASS
const fromEmail = process.env.SMTP_FROM_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
type EmailScenario = {
name: string
subject: string
render: () => Promise<string>
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tickets.esdrasrenan.com.br"
const scenarios: EmailScenario[] = [
{
name: "Ticket Criado",
subject: "[TESTE] Novo chamado #41025 aberto",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Novo chamado #41025 aberto",
message: "Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: Computador reiniciando sozinho\nPrioridade: Alta\nStatus: Pendente",
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Ticket Resolvido",
subject: "[TESTE] Chamado #41025 foi encerrado",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Chamado #41025 encerrado",
message: "O chamado 'Computador reiniciando sozinho' foi marcado como concluído.\n\nCaso necessário, você pode responder pelo portal para reabrir dentro do prazo.",
ctaLabel: "Ver detalhes",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Novo Comentário",
subject: "[TESTE] Atualização no chamado #41025",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Nova atualização no seu chamado #41025",
message: "Um novo comentário foi adicionado ao chamado 'Computador reiniciando sozinho'.\n\nClique abaixo para visualizar e responder pelo portal.",
ctaLabel: "Abrir e responder",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Mudança de Prioridade",
subject: "[TESTE] Prioridade alterada no chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Prioridade alterada para Urgente",
message: "A prioridade do seu chamado foi alterada automaticamente pelo sistema.\n\nIsso pode ter ocorrido devido a regras de SLA ou categorização automática.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "URGENT",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Automação - Atribuição de Agente",
subject: "[TESTE] Agente atribuído ao chamado #41025",
render: async () => {
const props: AutomationEmailProps = {
title: "Agente atribuído ao seu chamado",
message: "O agente Administrador foi automaticamente atribuído ao seu chamado e entrará em contato em breve.",
ticket: {
reference: 41025,
subject: "Computador reiniciando sozinho",
companyName: "Paulicon Contabil",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Renan",
assigneeName: "Administrador",
},
ctaLabel: "Ver chamado",
ctaUrl: `${baseUrl}/portal/tickets/test123`,
}
return render(<AutomationEmail {...props} />, { pretty: true })
},
},
{
name: "Redefinição de Senha",
subject: "[TESTE] Redefinição de senha - Raven",
render: async () => {
const props: SimpleNotificationEmailProps = {
title: "Redefinição de Senha",
message: "Recebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail.\n\nEste link expira em 1 hora.",
ctaLabel: "Redefinir Senha",
ctaUrl: `${baseUrl}/redefinir-senha?token=abc123def456`,
}
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
},
},
]
async function main() {
const targetEmail = process.argv[2] ?? "renan.pac@paulicon.com.br"
const smtp = getSmtpConfig()
if (!smtp) {
console.error("SMTP não configurado. Defina as variáveis SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL")
process.exit(1)
}
console.log("=".repeat(60))
console.log("Teste de E-mails - Sistema de Chamados Raven")
console.log("=".repeat(60))
console.log(`\nDestinatario: ${targetEmail}`)
console.log(`SMTP: ${smtp.host}:${smtp.port}`)
console.log(`De: ${smtp.from}`)
console.log(`\nEnviando ${scenarios.length} e-mails de teste...\n`)
let success = 0
let failed = 0
for (const scenario of scenarios) {
try {
process.stdout.write(` ${scenario.name}... `)
const html = await scenario.render()
await sendSmtpMail(smtp, targetEmail, scenario.subject, html)
console.log("OK")
success++
// Pequeno delay entre envios para evitar rate limit
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
console.log(`ERRO: ${error instanceof Error ? error.message : error}`)
failed++
}
}
console.log("\n" + "=".repeat(60))
console.log(`Resultado: ${success} enviados, ${failed} falharam`)
console.log("=".repeat(60))
if (failed > 0) {
process.exit(1)
}
}
main().catch((error) => {
console.error("Erro fatal:", error)
process.exit(1)
})

View file

@ -1,209 +0,0 @@
/**
* Script para testar envio de e-mail
* Uso: bun scripts/test-email.ts [destinatario]
*/
import { sendSmtpMail } from "../src/server/email-smtp"
import { renderTemplate } from "../src/server/email/email-templates"
const DESTINATARIO = process.argv[2] || "renan.pac@paulicon.com.br"
// Credenciais do SMTP (usando as da documentacao)
const smtpConfig = {
host: "smtp.c.inova.com.br",
port: 587,
username: "envio@rever.com.br",
password: "CAAJQm6ZT6AUdhXRTDYu",
from: '"Sistema de Chamados" <envio@rever.com.br>',
starttls: true,
tls: false,
rejectUnauthorized: false,
timeoutMs: 15000,
}
async function testEmail() {
console.log("=".repeat(50))
console.log("TESTE DE ENVIO DE E-MAIL")
console.log("=".repeat(50))
console.log(`Destinatario: ${DESTINATARIO}`)
console.log(`SMTP: ${smtpConfig.host}:${smtpConfig.port}`)
console.log("")
// 1. Teste basico
console.log("[1/10] Enviando e-mail de teste basico...")
try {
const html = renderTemplate("test", {
title: "Teste do Sistema de E-mail",
message: "Este e-mail confirma que o sistema de notificacoes esta funcionando corretamente.",
timestamp: new Date().toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" }),
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Teste - Sistema de Chamados Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 2. Teste de abertura de chamado
console.log("\n[2/10] Enviando notificacao de abertura de chamado...")
try {
const html = renderTemplate("ticket_created", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "PENDING",
priority: "HIGH",
createdAt: new Date().toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Aberto - Problema no sistema de vendas", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 3. Teste de resolucao de chamado
console.log("\n[3/10] Enviando notificacao de resolucao...")
try {
const html = renderTemplate("ticket_resolved", {
reference: 12345,
subject: "Problema no sistema de vendas",
assigneeName: "Joao Silva",
resolutionSummary: "O problema foi identificado como uma configuracao incorreta no modulo de pagamentos. A configuracao foi corrigida e o sistema esta funcionando normalmente.",
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
rateUrl: "https://tickets.esdrasrenan.com.br/rate/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Resolvido - Problema no sistema de vendas", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 4. Teste de comentario
console.log("\n[4/10] Enviando notificacao de comentario...")
try {
const html = renderTemplate("ticket_comment", {
reference: 12345,
subject: "Problema no sistema de vendas",
authorName: "Joao Silva",
commentBody: "Estou analisando o problema e em breve envio uma atualizacao. Por favor, verifique se o erro persiste apos limpar o cache do navegador.",
commentedAt: new Date().toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Nova atualizacao no Chamado #12345", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 5. Teste de atribuicao de chamado
console.log("\n[5/10] Enviando notificacao de atribuicao...")
try {
const html = renderTemplate("ticket_assigned", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Maria Santos",
assigneeName: "Joao Silva",
isForRequester: false,
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Atribuido", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 6. Teste de mudanca de status
console.log("\n[6/10] Enviando notificacao de mudanca de status...")
try {
const html = renderTemplate("ticket_status", {
reference: 12345,
subject: "Problema no sistema de vendas",
oldStatus: "PENDING",
newStatus: "AWAITING_ATTENDANCE",
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Status do Chamado #12345 Alterado", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 7. Teste de reset de senha
console.log("\n[7/10] Enviando notificacao de reset de senha...")
try {
const html = renderTemplate("password_reset", {
resetUrl: "https://tickets.esdrasrenan.com.br/reset-password?token=abc123",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Redefinicao de Senha - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 8. Teste de convite
console.log("\n[8/10] Enviando notificacao de convite...")
try {
const html = renderTemplate("invite", {
inviterName: "Admin Sistema",
roleName: "Agente",
companyName: "Empresa Teste",
inviteUrl: "https://tickets.esdrasrenan.com.br/invite?token=xyz789",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Voce foi convidado - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 9. Teste de novo login
console.log("\n[9/10] Enviando notificacao de novo login...")
try {
const html = renderTemplate("new_login", {
loginAt: new Date().toISOString(),
userAgent: "Chrome 120 no Windows 11",
ipAddress: "189.45.123.78",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "Novo Acesso Detectado - Raven", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
// 10. Teste de SLA em risco
console.log("\n[10/10] Enviando notificacao de SLA em risco...")
try {
const html = renderTemplate("sla_warning", {
reference: 12345,
subject: "Problema no sistema de vendas",
status: "AWAITING_ATTENDANCE",
priority: "HIGH",
requesterName: "Maria Santos",
assigneeName: "Joao Silva",
timeRemaining: "2 horas",
dueAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
})
await sendSmtpMail(smtpConfig, DESTINATARIO, "ALERTA: SLA em Risco - Chamado #12345", html)
console.log(" SUCESSO!")
} catch (error) {
console.error(" ERRO:", (error as Error).message)
}
console.log("\n" + "=".repeat(50))
console.log("TESTE CONCLUIDO - 10 TIPOS DE NOTIFICACAO")
console.log("=".repeat(50))
console.log(`Verifique a caixa de entrada de: ${DESTINATARIO}`)
}
testEmail().catch(console.error)

View file

@ -1,24 +1,63 @@
import pg from "pg"
import path from "node:path"
// NOTE: This helper imports the generated Prisma client from TypeScript files.
// Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun).
import { PrismaClient } from "../../src/generated/prisma/client.ts"
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"
const { Pool } = pg
const PROJECT_ROOT = process.cwd()
const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma")
export function createPrismaClient() {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required")
function resolveFileUrl(url) {
if (!url.startsWith("file:")) {
return url
}
const pool = new Pool({
connectionString: databaseUrl,
})
const filePath = url.slice("file:".length)
const adapter = new PrismaPg(pool)
if (filePath.startsWith("//")) {
return url
}
if (path.isAbsolute(filePath)) {
return `file:${path.normalize(filePath)}`
}
const normalized = path.normalize(filePath)
const prismaPrefix = `prisma${path.sep}`
const relativeToPrisma = normalized.startsWith(prismaPrefix)
? normalized.slice(prismaPrefix.length)
: normalized
const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma)
if (!absolutePath.startsWith(PROJECT_ROOT)) {
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
}
return `file:${absolutePath}`
}
function normalizeDatasourceUrl(envUrl) {
const trimmed = envUrl?.trim()
if (trimmed) {
return resolveFileUrl(trimmed)
}
if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite"
}
return resolveFileUrl("file:./db.dev.sqlite")
}
export function createPrismaClient() {
const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL)
process.env.DATABASE_URL = resolvedDatabaseUrl
const adapter = new PrismaBetterSqlite3({
url: resolvedDatabaseUrl,
})
return new PrismaClient({ adapter })
}

View file

@ -1,30 +0,0 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function POST() {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "CONVEX_URL não configurada" }, { status: 500 })
}
try {
const convex = new ConvexHttpClient(convexUrl)
const result = await convex.mutation(api.liveChat.fixLegacySessions, {})
return NextResponse.json({ success: true, result })
} catch (error) {
console.error("[fix-chat-sessions] Erro:", error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Falha ao corrigir sessões" },
{ status: 500 }
)
}
}

View file

@ -10,8 +10,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
import { notifyUserInvite } from "@/server/notification/notification-service"
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
const DEFAULT_EXPIRATION_DAYS = 7
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
@ -28,17 +27,6 @@ function normalizeRole(input: string | null | undefined): RoleOption {
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
}
function formatRoleName(role: string): string {
return ROLE_LABELS[role.toLowerCase()] ?? role
}
function generateToken() {
return randomBytes(32).toString("hex")
}
@ -225,24 +213,5 @@ export async function POST(request: Request) {
const normalized = buildInvitePayload(inviteWithEvents, now)
await syncInviteWithConvex(normalized)
// Envia email de convite
const inviteUrl = buildInviteUrl(token)
const inviterName = session.user.name ?? session.user.email
const roleName = formatRoleName(role)
try {
await notifyUserInvite(
email,
name ?? null,
inviterName,
roleName,
null, // companyName - não temos essa informação no convite
inviteUrl
)
} catch (error) {
// Log do erro mas não falha a criação do convite
console.error("[invites] Falha ao enviar email de convite:", error)
}
return NextResponse.json({ invite: normalized })
}

View file

@ -204,7 +204,7 @@ export async function POST(request: Request) {
})
const createdDomainUser = await tx.user.upsert({
where: { id: createdAuthUser.id },
where: { email },
update: {
name,
role: userRole,
@ -213,7 +213,6 @@ export async function POST(request: Request) {
managerId: managerRecord?.id ?? null,
},
create: {
id: createdAuthUser.id,
name,
email,
role: userRole,

View file

@ -1,101 +0,0 @@
import crypto from "crypto"
import { render } from "@react-email/render"
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { sendSmtpMail } from "@/server/email-smtp"
import SimpleNotificationEmail from "../../../../../emails/simple-notification-email"
function getSmtpConfig() {
const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS
const port = process.env.SMTP_PORT
const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME
const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD
const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
if (!host || !port || !username || !password || !fromEmail) return null
return {
host,
port: Number(port),
username,
password,
from: `"${fromName}" <${fromEmail}>`,
tls: process.env.SMTP_SECURE === "true",
starttls: process.env.SMTP_SECURE !== "true",
rejectUnauthorized: false,
timeoutMs: 15000,
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { email } = body
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 })
}
const normalizedEmail = email.toLowerCase().trim()
// Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança)
const user = await prisma.authUser.findFirst({
where: { email: normalizedEmail },
})
// Sempre retorna sucesso para não revelar se o e-mail existe
if (!user) {
return NextResponse.json({ success: true })
}
// Gera um token seguro
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora
// Remove tokens anteriores do mesmo usuário
await prisma.authVerification.deleteMany({
where: {
identifier: `password-reset:${user.id}`,
},
})
// Salva o novo token
await prisma.authVerification.create({
data: {
identifier: `password-reset:${user.id}`,
value: token,
expiresAt,
},
})
// Envia o e-mail
const smtp = getSmtpConfig()
if (!smtp) {
console.error("[FORGOT_PASSWORD] SMTP não configurado")
return NextResponse.json({ success: true }) // Não revela erro de configuração
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`
const html = await render(
SimpleNotificationEmail({
title: "Redefinição de Senha",
message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`,
ctaLabel: "Redefinir Senha",
ctaUrl: resetUrl,
}),
{ pretty: true }
)
await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html)
return NextResponse.json({ success: true })
} catch (error) {
console.error("[FORGOT_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 })
}
}

View file

@ -1,97 +0,0 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { prisma } from "@/lib/prisma"
export async function POST(request: Request) {
try {
const body = await request.json()
const { token, password } = body
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token inválido" }, { status: 400 })
}
if (!password || typeof password !== "string" || password.length < 6) {
return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 })
}
// Busca o token de verificação
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 })
}
// Extrai o userId do identifier
const userId = verification.identifier.replace("password-reset:", "")
// Busca o usuário
const user = await prisma.authUser.findUnique({
where: { id: userId },
})
if (!user) {
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 })
}
// Hash da nova senha
const hashedPassword = await hashPassword(password)
// Atualiza a conta do usuário com a nova senha
await prisma.authAccount.updateMany({
where: {
userId: user.id,
providerId: "credential",
},
data: {
password: hashedPassword,
},
})
// Remove o token usado
await prisma.authVerification.delete({
where: { id: verification.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro:", error)
return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 })
}
}
// GET para validar se o token é válido (usado pela página)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get("token")
if (!token) {
return NextResponse.json({ valid: false, error: "Token não fornecido" })
}
const verification = await prisma.authVerification.findFirst({
where: {
value: token,
identifier: { startsWith: "password-reset:" },
expiresAt: { gt: new Date() },
},
})
if (!verification) {
return NextResponse.json({ valid: false, error: "Token inválido ou expirado" })
}
return NextResponse.json({ valid: true })
} catch (error) {
console.error("[RESET_PASSWORD] Erro ao validar token:", error)
return NextResponse.json({ valid: false, error: "Erro ao validar token" })
}
}

Some files were not shown because too many files have changed in this diff Show more