diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7de2054..1a12f45 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,63 @@ "Bash(git commit:*)", "Bash(git push:*)", "Bash(cargo check:*)", - "Bash(bun run:*)" + "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 \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\")" ] } } diff --git a/.env.example b/.env.example index 1f0e247..739bb8b 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,9 @@ REPORTS_CRON_SECRET=reports-cron-secret # Diretório para arquivamento local de tickets (JSONL/backup) ARCHIVE_DIR=./archives -# PostgreSQL database -# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 +# 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 DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados # SMTP Configuration (production values in docs/SMTP.md) diff --git a/.forgejo/workflows/ci-cd-web-desktop.yml b/.forgejo/workflows/ci-cd-web-desktop.yml new file mode 100644 index 0000000..db80c21 --- /dev/null +++ b/.forgejo/workflows/ci-cd-web-desktop.yml @@ -0,0 +1,492 @@ +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:-}" + echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-}" + 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" diff --git a/.forgejo/workflows/quality-checks.yml b/.forgejo/workflows/quality-checks.yml new file mode 100644 index 0000000..daed18b --- /dev/null +++ b/.forgejo/workflows/quality-checks.yml @@ -0,0 +1,54 @@ +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 diff --git a/.github/workflows/ci-cd-web-desktop.yml b/.github/workflows.disabled/ci-cd-web-desktop.yml similarity index 100% rename from .github/workflows/ci-cd-web-desktop.yml rename to .github/workflows.disabled/ci-cd-web-desktop.yml diff --git a/.github/workflows/desktop-release.yml b/.github/workflows.disabled/desktop-release.yml similarity index 100% rename from .github/workflows/desktop-release.yml rename to .github/workflows.disabled/desktop-release.yml diff --git a/.github/workflows/quality-checks.yml b/.github/workflows.disabled/quality-checks.yml similarity index 100% rename from .github/workflows/quality-checks.yml rename to .github/workflows.disabled/quality-checks.yml diff --git a/.gitignore b/.gitignore index 80e6de6..30d6e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ rustdesk/ # Prisma generated files src/generated/ +apps/desktop/service/target/ diff --git a/Dockerfile.prod b/Dockerfile.prod index e08ccdc..bb79ec4 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,4 +1,4 @@ -# Runtime image with Node 22 + Bun 1.3.2 and build toolchain preinstalled +# Runtime image with Node 22 + Bun 1.3.4 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.2 +# Install Bun 1.3.4 RUN curl -fsSL https://bun.sh/install \ - | bash -s -- bun-v1.3.2 \ + | bash -s -- bun-v1.3.4 \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bun \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bunx diff --git a/agents.md b/agents.md index 0ed7d0c..fcac12d 100644 --- a/agents.md +++ b/agents.md @@ -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 (06/11/2025) -- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback). +## Stack atual (18/12/2025) +- **Next.js**: `16.0.10` (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.0`. +- **React / React DOM**: `19.2.1`. - **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=file:./prisma/db.dev.sqlite + DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados ``` 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): `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`). +- 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`). - 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. /home/renan/apps/sistema.current docker service update --force sistema_web ``` -- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`: +- Resolver `P3009` (migration falhou) no PostgreSQL ativo: ```bash docker service scale sistema_web=0 - docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \ + docker run --rm -it --network traefik_public \ + --env-file /home/renan/apps/sistema.current/.env \ -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 && bun x prisma migrate deploy" docker service scale sistema_web=1 ``` @@ -164,8 +164,51 @@ 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/DEPLOY-RUNBOOK.md` — runbook do Swarm. + - `docs/OPERATIONS.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 + + +// CORRETO - sem tooltip nativo + + +// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui + + + + + Remover item + +``` + +**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 + +``` + --- -_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._ +_Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 00e9106..1c403b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "node ./scripts/tauri-with-stub.mjs", - "gen:icon": "node ./scripts/build-icon.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" }, "dependencies": { "@radix-ui/react-scroll-area": "^1.2.3", @@ -19,6 +21,7 @@ "@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" diff --git a/apps/desktop/public/logo-raven.png b/apps/desktop/public/logo-raven.png new file mode 100644 index 0000000..62b264e Binary files /dev/null and b/apps/desktop/public/logo-raven.png differ diff --git a/apps/desktop/service/Cargo.lock b/apps/desktop/service/Cargo.lock new file mode 100644 index 0000000..da860fc --- /dev/null +++ b/apps/desktop/service/Cargo.lock @@ -0,0 +1,1931 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raven-service" +version = "0.1.0" +dependencies = [ + "chrono", + "interprocess", + "once_cell", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "windows", + "windows-service", + "winreg", +] + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/apps/desktop/service/Cargo.toml b/apps/desktop/service/Cargo.toml new file mode 100644 index 0000000..a1334d5 --- /dev/null +++ b/apps/desktop/service/Cargo.toml @@ -0,0 +1,70 @@ +[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 diff --git a/apps/desktop/service/src/ipc.rs b/apps/desktop/service/src/ipc.rs new file mode 100644 index 0000000..26091b6 --- /dev/null +++ b/apps/desktop/service/src/ipc.rs @@ -0,0 +1,290 @@ +//! 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(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[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 = 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::() 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::(&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)), + } +} diff --git a/apps/desktop/service/src/main.rs b/apps/desktop/service/src/main.rs new file mode 100644 index 0000000..208e22c --- /dev/null +++ b/apps/desktop/service/src/main.rs @@ -0,0 +1,268 @@ +//! 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> { + // Configura logging + init_logging(); + + // Verifica argumentos de linha de comando + let args: Vec = 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) { + if let Err(e) = run_service(arguments) { + error!("Erro ao executar servico: {}", e); + } +} + +fn run_service(_arguments: Vec) -> Result<(), Box> { + 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> { + 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> { + 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> { + 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(()) +} diff --git a/apps/desktop/service/src/rustdesk.rs b/apps/desktop/service/src/rustdesk.rs new file mode 100644 index 0000000..0df60aa --- /dev/null +++ b/apps/desktop/service/src/rustdesk.rs @@ -0,0 +1,846 @@ +//! 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> = 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 }, + + #[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, + 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, + pub version: Option, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct ReleaseResponse { + tag_name: String, + assets: Vec, +} + +/// Provisiona o RustDesk +pub fn ensure_rustdesk( + config_string: Option<&str>, + password_override: Option<&str>, + machine_id: Option<&str>, +) -> Result { + 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 { + 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, 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 { + 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 { + vec![ + PathBuf::from(LOCAL_SERVICE_CONFIG), + PathBuf::from(LOCAL_SYSTEM_CONFIG), + ] +} + +fn remote_id_directories() -> Vec { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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", ®_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) -> Command { + let mut cmd = Command::new(program); + cmd.creation_flags(CREATE_NO_WINDOW); + cmd +} diff --git a/apps/desktop/service/src/usb_policy.rs b/apps/desktop/service/src/usb_policy.rs new file mode 100644 index 0000000..ed8144d --- /dev/null +++ b/apps/desktop/service/src/usb_policy.rs @@ -0,0 +1,259 @@ +//! 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 { + 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, + pub applied_at: Option, +} + +#[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 { + 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 { + 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()) +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 86e04da..f5d4b76 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -63,6 +63,7 @@ dependencies = [ "base64 0.22.1", "chrono", "convex", + "dirs 5.0.1", "futures-util", "get_if_addrs", "hostname", @@ -80,10 +81,12 @@ 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", ] @@ -936,13 +939,34 @@ 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", + "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", ] [[package]] @@ -953,7 +977,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3627,6 +3651,17 @@ 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" @@ -4514,7 +4549,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.3", @@ -4564,7 +4599,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4748,6 +4783,21 @@ 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" @@ -4771,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5307,7 +5357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -6088,6 +6138,15 @@ 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" @@ -6139,6 +6198,21 @@ 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" @@ -6196,6 +6270,12 @@ 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" @@ -6214,6 +6294,12 @@ 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" @@ -6232,6 +6318,12 @@ 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" @@ -6262,6 +6354,12 @@ 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" @@ -6280,6 +6378,12 @@ 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" @@ -6298,6 +6402,12 @@ 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" @@ -6316,6 +6426,12 @@ 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" @@ -6378,7 +6494,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index efa7052..8e26952 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ 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"] } @@ -41,6 +42,8 @@ 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] diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index e633b09..a0cf79b 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for all windows", - "windows": ["main", "chat-*"], + "windows": ["main", "chat-*", "chat-hub"], "permissions": [ "core:default", "core:event:default", @@ -14,6 +14,7 @@ "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", diff --git a/apps/desktop/src-tauri/installer-hooks.nsh b/apps/desktop/src-tauri/installer-hooks.nsh index f6e32c6..72de836 100644 --- a/apps/desktop/src-tauri/installer-hooks.nsh +++ b/apps/desktop/src-tauri/installer-hooks.nsh @@ -1,20 +1,121 @@ ; Hooks customizadas do instalador NSIS (Tauri) ; -; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo. +; Objetivo: +; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo +; - Instalar o Raven Service para operacoes privilegiadas sem UAC ; ; 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 diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index b663005..1d42d23 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value { } fn decode_utf16_le_to_string(bytes: &[u8]) -> Option { - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return None; } let utf16: Vec = bytes @@ -971,6 +971,169 @@ 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, @@ -992,6 +1155,12 @@ 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, } }) } @@ -1086,7 +1255,7 @@ pub fn collect_profile() -> Result { 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(); @@ -1146,7 +1315,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()), @@ -1225,7 +1394,8 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) { #[cfg(target_os = "windows")] { - use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy}; + use crate::usb_control::{get_current_policy, UsbPolicy}; + use crate::service_client; let policy = match UsbPolicy::from_str(&policy_str) { Some(p) => p, @@ -1259,24 +1429,58 @@ 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; - match apply_usb_policy(policy) { + // Tenta primeiro via RavenService (privilegiado) + crate::log_info!("Tentando aplicar politica via RavenService..."); + match service_client::apply_usb_policy(&policy_str) { Ok(result) => { - 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; - }); + 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; + } } } Err(e) => { - crate::log_error!("Falha ao aplicar politica USB: {e}"); + crate::log_error!("Falha ao comunicar com RavenService: {e}"); report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await; } } diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index d2e52f3..b6f237b 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -10,10 +10,12 @@ use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; @@ -100,6 +102,77 @@ 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, + 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 { + 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 { + 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 // ============================================================================ @@ -462,10 +535,16 @@ 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(Vec::new())), - last_unread_count: Arc::new(Mutex::new(0)), + last_sessions: Arc::new(Mutex::new(sessions)), + last_unread_count: Arc::new(Mutex::new(unread)), is_connected: Arc::new(AtomicBool::new(false)), } } @@ -510,7 +589,9 @@ impl ChatRuntime { let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); + 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); let mut backoff_ms: u64 = 1_000; let max_backoff_ms: u64 = 30_000; @@ -522,12 +603,16 @@ 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) => c, + Ok(c) => { + crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso"); + c + } Err(err) => { is_connected.store(false, Ordering::Relaxed); - crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); + crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -550,16 +635,18 @@ 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!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); + crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -579,8 +666,12 @@ 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 { @@ -601,6 +692,11 @@ 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, @@ -613,13 +709,13 @@ impl ChatRuntime { .await; } FunctionResult::ConvexError(err) => { - crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); + crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}"); } FunctionResult::ErrorMessage(msg) => { - crate::log_warn!("Erro em checkMachineUpdates: {msg}"); + crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}"); } FunctionResult::Value(other) => { - crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); + crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}"); } } } @@ -627,10 +723,11 @@ 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 realtime desconectado; aplicando fallback e tentando reconectar"); + crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar..."); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, @@ -684,8 +781,13 @@ async fn poll_and_process_chat_update( last_sessions: &Arc>>, last_unread_count: &Arc>, ) { + 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, @@ -698,7 +800,7 @@ async fn poll_and_process_chat_update( .await; } Err(err) => { - crate::log_warn!("Chat fallback poll falhou: {err}"); + crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}"); } } } @@ -712,10 +814,18 @@ 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 { - fetch_sessions(base_url, token).await.unwrap_or_default() + let sessions = fetch_sessions(base_url, token).await.unwrap_or_default(); + crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len()); + sessions } else { + crate::log_info!("[CHAT DEBUG] Sem sessoes ativas"); Vec::new() }; @@ -776,14 +886,58 @@ async fn process_chat_update( } } - // Atualizar cache de sessoes - *last_sessions.lock() = current_sessions.clone(); + // ========================================================================= + // DETECCAO ROBUSTA DE NOVAS MENSAGENS + // Usa DUAS estrategias: timestamp E contador (belt and suspenders) + // ========================================================================= - // Verificar mensagens nao lidas let prev_unread = *last_unread_count.lock(); - let new_messages = total_unread > prev_unread; + + // 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 ¤t_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) + *last_sessions.lock() = current_sessions.clone(); *last_unread_count.lock() = total_unread; + // Persistir estado para sobreviver a restarts + save_chat_state(total_unread, ¤t_sessions); + // Sempre emitir unread-update let _ = app.emit( "raven://chat/unread-update", @@ -795,9 +949,17 @@ async fn process_chat_update( // Notificar novas mensagens - mostrar chat minimizado com badge if new_messages && total_unread > 0 { - let new_count = total_unread - prev_unread; + 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 + }; - crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread); + crate::log_info!( + "[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}", + new_count, total_unread, + if detected_by_activity { "activity" } else { "count" } + ); let _ = app.emit( "raven://chat/new-message", @@ -838,37 +1000,34 @@ async fn process_chat_update( } } - // 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 { - 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 (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 - } + // 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 { - // Criar nova janela ja minimizada (menos intrusivo) - let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + close_all_chat_windows(app); + let _ = open_hub_window(app); + } + } else { + // Uma sessao - nao precisa de hub + let _ = close_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 { + 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); } } @@ -885,6 +1044,16 @@ async fn process_chat_update( .title(notification_title) .body(¬ification_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 + ); + } } } @@ -892,6 +1061,53 @@ async fn process_chat_update( // WINDOW MANAGEMENT // ============================================================================ +// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2). +static WINDOW_OP_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); +static CHAT_WINDOW_STATE: Lazy>> = 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 = 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>, @@ -932,18 +1148,44 @@ fn resolve_chat_window_position( (x, y) } -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 +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) } /// 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())?; - window.set_focus().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); + } return Ok(()); } @@ -960,7 +1202,17 @@ 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); - WebviewWindowBuilder::new( + crate::log_info!( + "[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}", + label, + width, + height, + x, + y, + url_path + ); + + let window = WebviewWindowBuilder::new( app, &label, WebviewUrl::App(url_path.into()), @@ -972,46 +1224,64 @@ 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(true) + .focused(!start_minimized) .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(app, ticket_id, start_minimized); + let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized); + crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, 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> { - open_chat_window_internal(app, ticket_id, ticket_ref) + // Quando chamado explicitamente (ex: clique no hub), abre expandida + open_chat_window_internal(app, ticket_id, ticket_ref, false) } 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 -pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { +fn set_chat_minimized_unlocked(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 @@ -1023,9 +1293,125 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo 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(()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ece1ae8..5e3939e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ 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}; @@ -68,21 +70,21 @@ pub fn log_agent(level: &str, message: &str) { #[macro_export] macro_rules! log_info { ($($arg:tt)*) => { - $crate::log_agent("INFO", &format!($($arg)*)) + $crate::log_agent("INFO", format!($($arg)*).as_str()) }; } #[macro_export] macro_rules! log_error { ($($arg:tt)*) => { - $crate::log_agent("ERROR", &format!($($arg)*)) + $crate::log_agent("ERROR", format!($($arg)*).as_str()) }; } #[macro_export] macro_rules! log_warn { ($($arg:tt)*) => { - $crate::log_agent("WARN", &format!($($arg)*)) + $crate::log_agent("WARN", format!($($arg)*).as_str()) }; } @@ -189,6 +191,32 @@ fn run_rustdesk_ensure( password: Option, machine_id: Option, ) -> Result { + // 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(), @@ -208,14 +236,50 @@ fn run_rustdesk_ensure( #[tauri::command] fn apply_usb_policy(policy: String) -> Result { - let policy_enum = UsbPolicy::from_str(&policy) + // Valida a politica primeiro + let _policy_enum = UsbPolicy::from_str(&policy) .ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?; - usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string()) + // 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()) } #[tauri::command] fn get_usb_policy() -> Result { + // 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()) @@ -346,8 +410,17 @@ async fn upload_chat_file( } #[tauri::command] -fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> { - chat::open_chat_window(&app, &ticket_id, ticket_ref) +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 } #[tauri::command] @@ -365,6 +438,26 @@ 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://) // ============================================================================ @@ -452,6 +545,14 @@ 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(); @@ -481,7 +582,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(); @@ -526,7 +627,11 @@ pub fn run() { open_chat_window, close_chat_window, minimize_chat_window, - set_chat_minimized + set_chat_minimized, + // Hub commands + open_hub_window, + close_hub_window, + set_hub_minimized ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -608,7 +713,13 @@ 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::() { let sessions = chat_runtime.get_sessions(); - if let Some(session) = sessions.first() { + 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 Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) { log_error!("Falha ao abrir janela de chat: {e}"); } diff --git a/apps/desktop/src-tauri/src/rustdesk.rs b/apps/desktop/src-tauri/src/rustdesk.rs index ef4a81f..8c6cee4 100644 --- a/apps/desktop/src-tauri/src/rustdesk.rs +++ b/apps/desktop/src-tauri/src/rustdesk.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use crate::RustdeskProvisioningResult; use chrono::{Local, Utc}; use once_cell::sync::Lazy; @@ -30,7 +28,9 @@ 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 { 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 { 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 { 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 { 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 { 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,13 +981,11 @@ 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) => { - cleaned_any = true; - log_event(&format!( + log_event(format!( "Perfis antigos removidos em {}", dir.display() )); @@ -997,9 +995,7 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> { } } - if cleaned_any { - Ok(()) - } else if errors.is_empty() { + if errors.is_empty() { Ok(()) } else { Err(errors.join(" | ")) @@ -1030,6 +1026,7 @@ fn purge_config_dir(dir: &Path) -> Result { 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"); @@ -1077,6 +1074,7 @@ 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"); @@ -1111,7 +1109,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 )); @@ -1122,6 +1120,9 @@ 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) { @@ -1133,53 +1134,46 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> { return Ok(()); } - 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)"); - } + // 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::>() + )); - let mut last_error: Option = 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())) - } + // Retornamos Ok para não bloquear o fluxo + // O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios + Ok(()) } fn stop_service_elevated() -> Result<(), String> { - 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) + // 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(()) + } + } } fn can_write_dir(dir: &Path) -> bool { @@ -1339,21 +1333,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() )); @@ -1469,21 +1463,24 @@ fn write_machine_store_object(map: JsonMap) -> Result<(), Str } fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> { - let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new); + let mut map = read_machine_store_object().unwrap_or_default(); 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 { 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() { @@ -1493,6 +1490,7 @@ 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() { @@ -1500,7 +1498,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() )); @@ -1508,7 +1506,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}" )); } @@ -1547,7 +1545,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? } @@ -1575,13 +1573,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) diff --git a/apps/desktop/src-tauri/src/service_client.rs b/apps/desktop/src-tauri/src/service_client.rs new file mode 100644 index 0000000..f2af2ed --- /dev/null +++ b/apps/desktop/src-tauri/src/service_client.rs @@ -0,0 +1,244 @@ +//! 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, + error: Option, +} + +#[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, + pub applied_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskResult { + pub id: String, + pub password: String, + pub installed_version: Option, + 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, + pub version: Option, +} + +#[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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + Err(ServiceClientError::ServiceUnavailable( + "Named Pipes so estao disponiveis no Windows".into(), + )) +} diff --git a/apps/desktop/src-tauri/src/usb_control.rs b/apps/desktop/src-tauri/src/usb_control.rs index 24eebfa..a95e0a5 100644 --- a/apps/desktop/src-tauri/src/usb_control.rs +++ b/apps/desktop/src-tauri/src/usb_control.rs @@ -93,22 +93,10 @@ mod windows_impl { applied_at: Some(now), }), Err(err) => { - // Tenta elevação se faltou permissão + // Se faltou permissão, retorna erro - o serviço deve ser usado + // Não fazemos elevação aqui para evitar UAC adicional if is_permission_error(&err) { - 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), - }); + return Err(UsbControlError::PermissionDenied); } Err(err) } @@ -219,10 +207,8 @@ 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(()) @@ -269,6 +255,7 @@ 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(); @@ -321,7 +308,7 @@ try {{ policy = policy_str ); - fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?; + fs::write(&script_path, script).map_err(UsbControlError::Io)?; // Start-Process com RunAs para acionar UAC let arg = format!( @@ -333,7 +320,7 @@ try {{ .arg("-Command") .arg(arg) .status() - .map_err(|e| UsbControlError::Io(e))?; + .map_err(UsbControlError::Io)?; if !status.success() { return Err(UsbControlError::PermissionDenied); @@ -362,7 +349,7 @@ try {{ .args(["/target:computer", "/force"]) .creation_flags(CREATE_NO_WINDOW) .output() - .map_err(|e| UsbControlError::Io(e))?; + .map_err(UsbControlError::Io)?; if !output.status.success() { // Nao e critico se falhar, apenas log diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d7672b5..b9a94d1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,6 +50,9 @@ "icons/icon.png", "icons/Raven.png" ], + "resources": { + "../service/target/release/raven-service.exe": "raven-service.exe" + }, "windows": { "webviewInstallMode": { "type": "skip" diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx new file mode 100644 index 0000000..04358d9 --- /dev/null +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -0,0 +1,256 @@ +/** + * 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 ( +
+
+ Token nao configurado +
+
+ ) + } + + // Loading + if (isLoading) { + return ( +
+
+ + Carregando... +
+
+ ) + } + + // Sem sessoes ativas + if (sessions.length === 0) { + return ( +
+
+ + Sem chats +
+
+ ) + } + + // Minimizado + if (isMinimized) { + return ( +
+ +
+ ) + } + + // Expandido + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Chats Ativos

+

+ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} +

+
+
+
+ + +
+
+ + {/* Lista de sessoes */} +
+
+ {sessions.map((session) => ( + handleSelectSession(session.ticketId, session.ticketRef)} + /> + ))} +
+
+
+ ) +} + +function SessionItem({ + session, + onClick, +}: { + session: MachineSession + onClick: () => void +}) { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + onClick() + } + + return ( + + ) +} + +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` +} diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index e4964fc..3c5e9c5 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,25 +1,26 @@ +/** + * ChatWidget - Componente de chat em tempo real usando Convex subscriptions + * + * Arquitetura: + * - Usa useQuery do Convex React para subscriptions reativas (tempo real verdadeiro) + * - Usa useMutation do Convex React para enviar mensagens + * - Mantém Tauri apenas para: upload de arquivos, gerenciamento de janela + * - Sem polling - todas as atualizacoes sao push-based via WebSocket + */ + import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { open as openDialog } from "@tauri-apps/plugin-dialog" import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" -import { listen } from "@tauri-apps/api/event" -import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" -import type { - ChatAttachment, - ChatMessage, - ChatMessagesResponse, - NewMessageEvent, - SessionEndedEvent, - SessionStartedEvent, - UnreadUpdateEvent, -} from "./types" -import { getMachineStoreConfig } from "./machineStore" +import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check, MessagesSquare } from "lucide-react" +import type { Id } from "@convex/_generated/dataModel" +import { useMachineMessages, useMachineSessions, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries" +import { useConvexMachine } from "./ConvexMachineProvider" -const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak +const MAX_MESSAGES_IN_MEMORY = 200 const MARK_READ_BATCH_SIZE = 50 const SCROLL_BOTTOM_THRESHOLD_PX = 120 -// Tipos de arquivo permitidos const ALLOWED_EXTENSIONS = [ "jpg", "jpeg", "png", "gif", "webp", "pdf", "txt", "doc", "docx", "xls", "xlsx", @@ -32,6 +33,13 @@ interface UploadedAttachment { type?: string } +interface ChatAttachment { + storageId: string + name: string + size?: number + type?: string +} + function getFileIcon(fileName: string) { const ext = fileName.toLowerCase().split(".").pop() ?? "" if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) { @@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) { return `${(kb / 1024).toFixed(1)}MB` } -function getUnreadAgentMessageIds(messages: ChatMessage[], unreadCount: number): string[] { +function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] { if (unreadCount <= 0 || messages.length === 0) return [] const ids: string[] = [] for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) { @@ -138,7 +146,6 @@ function MessageAttachment({ setTimeout(() => setDownloaded(false), 2000) } catch (err) { console.error("Falha ao baixar anexo:", err) - // Fallback: abrir no navegador/sistema await handleView() } finally { setDownloading(false) @@ -160,7 +167,7 @@ function MessageAttachment({ if (isImage && url) { return (
- {/* eslint-disable-next-line @next/next/no-img-element */} + {/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */} {attachment.name} @@ -179,7 +186,7 @@ function MessageAttachment({ onClick={handleDownload} disabled={downloading} className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60" - title="Baixar" + aria-label="Baixar anexo" > {downloading ? ( @@ -197,7 +204,11 @@ function MessageAttachment({ return (
{getFileIcon(attachment.name)} - {sizeLabel && ({sizeLabel})} @@ -205,7 +216,7 @@ function MessageAttachment({ @@ -213,7 +224,7 @@ function MessageAttachment({ onClick={handleDownload} disabled={downloading} className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`} - title="Baixar" + aria-label="Baixar anexo" > {downloading ? ( @@ -234,24 +245,30 @@ interface ChatWidgetProps { } export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { - const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState("") - const [isLoading, setIsLoading] = useState(true) const [isSending, setIsSending] = useState(false) const [isUploading, setIsUploading] = useState(false) - const [error, setError] = useState(null) - const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null) - const [hasSession, setHasSession] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) - // Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente - const [isMinimized, setIsMinimized] = useState(true) - const [unreadCount, setUnreadCount] = useState(0) + // Inicializa baseado na altura real da janela (< 100px = minimizado) + const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100) + + // Convex hooks + const { apiBaseUrl, machineToken } = useConvexMachine() + const { sessions: machineSessions = [] } = useMachineSessions() + const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages( + ticketId as Id<"tickets">, + { limit: MAX_MESSAGES_IN_MEMORY } + ) + const postMessage = usePostMachineMessage() + const markMessagesRead = useMarkMachineMessagesRead() + + // Limitar mensagens em memoria + const messages = useMemo(() => convexMessages.slice(-MAX_MESSAGES_IN_MEMORY), [convexMessages]) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) const messageElementsRef = useRef>(new Map()) const prevHasSessionRef = useRef(false) - const retryDelayMsRef = useRef(1_000) const [isAtBottom, setIsAtBottom] = useState(true) const isAtBottomRef = useRef(true) @@ -260,10 +277,28 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { | { type: "message"; messageId: string; behavior: ScrollBehavior; markRead: boolean } | null >(null) + const autoReadInFlightRef = useRef(false) + const lastAutoReadCountRef = useRef(null) const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount]) const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null + const otherUnreadCount = useMemo(() => { + if (machineSessions.length <= 1) return 0 + return machineSessions.reduce((sum, session) => { + return sum + (session.ticketId === ticketId ? 0 : session.unreadCount) + }, 0) + }, [machineSessions, ticketId]) + + const handleOpenHub = useCallback(async () => { + try { + await invoke("open_hub_window") + await invoke("set_hub_minimized", { minimized: false }) + } catch (err) { + console.error("Erro ao abrir hub:", err) + } + }, []) + const updateIsAtBottom = useCallback(() => { const el = messagesContainerRef.current if (!el) return @@ -288,43 +323,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { return true }, [updateIsAtBottom]) - // Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso + // Fechar janela quando sessao termina useEffect(() => { const prevHasSession = prevHasSessionRef.current if (prevHasSession && !hasSession) { invoke("close_chat_window", { ticketId }).catch((err) => { - console.error("Erro ao fechar janela ao encerrar sessão:", err) + console.error("Erro ao fechar janela ao encerrar sessao:", err) }) } prevHasSessionRef.current = hasSession }, [hasSession, ticketId]) - // Ref para acessar isMinimized dentro do callback sem causar resubscription + // Ref para acessar isMinimized dentro de callbacks const isMinimizedRef = useRef(isMinimized) useEffect(() => { isMinimizedRef.current = isMinimized }, [isMinimized]) - const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) - - const ensureConfig = useCallback(async () => { - const cfg = configRef.current ?? (await getMachineStoreConfig()) - configRef.current = cfg - return cfg - }, []) - + // Cache de URLs de anexos const attachmentUrlCacheRef = useRef>(new Map()) const loadAttachmentUrl = useCallback(async (storageId: string) => { const cached = attachmentUrlCacheRef.current.get(storageId) if (cached) return cached - const cfg = await ensureConfig() - const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, { + if (!apiBaseUrl || !machineToken) { + throw new Error("Configuracao nao disponivel") + } + + const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - machineToken: cfg.token, + machineToken, ticketId, storageId, }), @@ -342,148 +373,50 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { attachmentUrlCacheRef.current.set(storageId, data.url) return data.url - }, [ensureConfig, ticketId]) - - const loadMessages = useCallback(async () => { - try { - const cfg = await ensureConfig() - const result = await invoke("fetch_chat_messages", { - baseUrl: cfg.apiBaseUrl, - token: cfg.token, - ticketId, - since: null, - }) - - setHasSession(result.hasSession) - setUnreadCount(result.unreadCount ?? 0) - setMessages(result.messages.slice(-MAX_MESSAGES_IN_MEMORY)) - - if (result.messages.length > 0) { - const first = result.messages[0] - setTicketInfo((prevInfo) => - prevInfo ?? { - ref: ticketRef ?? 0, - subject: "", - agentName: first.authorName ?? "Suporte", - } - ) - } - - setError(null) - retryDelayMsRef.current = 1_000 - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message || "Erro ao carregar mensagens.") - } finally { - setIsLoading(false) - } - }, [ensureConfig, ticketId, ticketRef]) - - // Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente). - useEffect(() => { - if (!error) return - - const delayMs = retryDelayMsRef.current - const timeout = window.setTimeout(() => { - loadMessages() - }, delayMs) - - retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000) - - return () => { - window.clearTimeout(timeout) - } - }, [error, loadMessages]) + }, [apiBaseUrl, machineToken, ticketId]) const markUnreadMessagesRead = useCallback(async () => { - if (unreadCount <= 0) return + if (unreadCount <= 0) return false const ids = getUnreadAgentMessageIds(messages, unreadCount) - if (ids.length === 0) return + if (ids.length === 0) return false - const cfg = await ensureConfig() const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE) - for (const chunk of chunks) { - await invoke("mark_chat_messages_read", { - baseUrl: cfg.apiBaseUrl, - token: cfg.token, - ticketId, - messageIds: chunk, + await markMessagesRead({ + ticketId: ticketId as Id<"tickets">, + messageIds: chunk as Id<"ticketChatMessages">[], }) } + return true + }, [messages, ticketId, unreadCount, markMessagesRead]) - setUnreadCount(0) - }, [ensureConfig, messages, ticketId, unreadCount]) + const maybeAutoMarkRead = useCallback(async () => { + if (autoReadInFlightRef.current) return + if (!hasSession || unreadCount <= 0) return + if (isMinimizedRef.current || !isAtBottomRef.current) return + if (lastAutoReadCountRef.current === unreadCount) return - // Carregar mensagens na montagem / troca de ticket - useEffect(() => { - setIsLoading(true) - setMessages([]) - setUnreadCount(0) - loadMessages() - }, [loadMessages]) - - // Recarregar quando o Rust sinalizar novas mensagens para este ticket - useEffect(() => { - let unlisten: (() => void) | null = null - listen("raven://chat/new-message", (event) => { - const sessions = event.payload?.sessions ?? [] - if (sessions.some((s) => s.ticketId === ticketId)) { - const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current - if (shouldAutoScroll) { - pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } - } - loadMessages() + autoReadInFlightRef.current = true + try { + const didMark = await markUnreadMessagesRead() + if (didMark) { + lastAutoReadCountRef.current = unreadCount } - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener new-message:", err)) - - return () => { - unlisten?.() + } finally { + autoReadInFlightRef.current = false } - }, [ticketId, loadMessages]) + }, [hasSession, unreadCount, markUnreadMessagesRead]) - // Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline") + // Auto-scroll quando novas mensagens chegam (se ja estava no bottom) + const prevMessagesLengthRef = useRef(messages.length) useEffect(() => { - let unlisten: (() => void) | null = null - listen("raven://chat/session-started", (event) => { - if (event.payload?.session?.ticketId === ticketId) { - loadMessages() - } - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener session-started:", err)) - - return () => { - unlisten?.() + if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) { + pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } } - }, [ticketId, loadMessages]) + prevMessagesLengthRef.current = messages.length + }, [messages.length]) - // Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida) - useEffect(() => { - let unlisten: (() => void) | null = null - - listen("raven://chat/unread-update", (event) => { - const sessions = event.payload?.sessions ?? [] - const session = sessions.find((s) => s.ticketId === ticketId) - setUnreadCount(session?.unreadCount ?? 0) - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener unread-update:", err)) - - return () => { - unlisten?.() - } - }, [ticketId]) - - // Executar scroll pendente (após expandir ou após novas mensagens) + // Executar scroll pendente useEffect(() => { if (isMinimized) return @@ -517,84 +450,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage]) - // Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente) useEffect(() => { - let unlisten: (() => void) | null = null - listen("raven://chat/session-ended", (event) => { - if (event.payload?.ticketId === ticketId) { - loadMessages() - } - }) - .then((u) => { - unlisten = u - }) - .catch((err) => console.error("Falha ao registrar listener session-ended:", err)) - - return () => { - unlisten?.() + if (unreadCount === 0) { + lastAutoReadCountRef.current = null + return } - }, [ticketId, loadMessages]) + maybeAutoMarkRead().catch((err) => console.error("Falha ao auto-marcar mensagens:", err)) + }, [isMinimized, isAtBottom, unreadCount, maybeAutoMarkRead]) - // Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions - /* useEffect(() => { - setIsLoading(true) - setMessages([]) - messagesSubRef.current?.() - // reset contador ao trocar ticket - setUnreadCount(0) - - subscribeMachineMessages( - ticketId, - (payload) => { - setIsLoading(false) - setHasSession(payload.hasSession) - hadSessionRef.current = hadSessionRef.current || payload.hasSession - // Usa o unreadCount do backend (baseado em unreadByMachine da sessao) - const backendUnreadCount = (payload as { unreadCount?: number }).unreadCount ?? 0 - setUnreadCount(backendUnreadCount) - setMessages(prev => { - const existingIds = new Set(prev.map(m => m.id)) - const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))] - return combined.slice(-MAX_MESSAGES_IN_MEMORY) - }) - // Atualiza info basica do ticket - if (payload.messages.length > 0) { - const first = payload.messages[0] - setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" }) - } - // NAO marca como lidas aqui - deixa o useEffect de expansao fazer isso - // Isso evita marcar como lidas antes do usuario expandir o chat - }, - (err) => { - setIsLoading(false) - setError(err.message || "Erro ao carregar mensagens.") - } - ).then((unsub) => { - messagesSubRef.current = unsub - }) - - return () => { - messagesSubRef.current?.() - messagesSubRef.current = null - } - }, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions - - // Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem) - // O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem + // Sincronizar estado minimizado com tamanho da janela useEffect(() => { - // Ignorar todos os eventos de resize nos primeiros 500ms apos a montagem - // Isso da tempo ao Tauri de aplicar o tamanho correto da janela - // e evita que resizes transitórios durante a criação da janela alterem o estado const mountTime = Date.now() - const STABILIZATION_DELAY = 500 // ms para a janela estabilizar + const STABILIZATION_DELAY = 500 const handler = () => { - // Ignorar eventos de resize durante o periodo de estabilizacao if (Date.now() - mountTime < STABILIZATION_DELAY) { return } const h = window.innerHeight - // thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido) setIsMinimized(h < 100) } window.addEventListener("resize", handler) @@ -616,16 +489,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { if (!selected) return - // O retorno pode ser string (path único) ou objeto com path const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path setIsUploading(true) - const config = await getMachineStoreConfig() + if (!apiBaseUrl || !machineToken) { + throw new Error("Configuracao nao disponivel") + } const attachment = await invoke("upload_chat_file", { - baseUrl: config.apiBaseUrl, - token: config.token, + baseUrl: apiBaseUrl, + token: machineToken, filePath, }) @@ -654,34 +528,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsSending(true) try { - const bodyToSend = messageText - const cfg = await ensureConfig() - await invoke("send_chat_message", { - baseUrl: cfg.apiBaseUrl, - token: cfg.token, - ticketId, - body: bodyToSend, - attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, - }) - - // Adicionar mensagem localmente - setMessages(prev => [...prev, { - id: crypto.randomUUID(), - body: bodyToSend, - authorName: "Você", - isFromMachine: true, - createdAt: Date.now(), - attachments: attachmentsToSend.map(a => ({ - storageId: a.storageId, + await postMessage({ + ticketId: ticketId as Id<"tickets">, + body: messageText, + attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({ + storageId: a.storageId as Id<"_storage">, name: a.name, size: a.size, type: a.type, - })), - }]) + })) : undefined, + }) pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false } } catch (err) { console.error("Erro ao enviar mensagem:", err) - // Restaurar input e anexos em caso de erro setInputValue(messageText) setPendingAttachments(attachmentsToSend) } finally { @@ -692,6 +551,11 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const handleMinimize = async () => { setIsMinimized(true) try { + if (machineSessions.length > 1) { + await invoke("open_hub_window") + await invoke("close_chat_window", { ticketId }) + return + } await invoke("set_chat_minimized", { ticketId, minimized: true }) } catch (err) { console.error("Erro ao minimizar janela:", err) @@ -707,14 +571,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsMinimized(false) try { - await invoke("set_chat_minimized", { ticketId, minimized: false }) + await invoke("open_chat_window", { ticketId, ticketRef: ticketRef ?? 0 }) } catch (err) { console.error("Erro ao expandir janela:", err) } } const handleClose = () => { - invoke("close_chat_window", { ticketId }) + invoke("close_chat_window", { ticketId }).catch((err) => { + console.error("Erro ao fechar janela de chat:", err) + }) } const handleKeyDown = (e: React.KeyboardEvent) => { @@ -724,9 +590,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } } + // Loading if (isLoading) { - // Mostrar chip compacto enquanto carrega (compativel com janela minimizada) - // pointer-events-none no container para que a area transparente nao seja clicavel return (
@@ -737,38 +602,21 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - if (error) { - // Mostrar chip compacto de erro (compativel com janela minimizada) - return ( -
- -
- ) - } - - // Quando não há sessão, mostrar versão minimizada com indicador de offline + // Sem sessao ativa if (!hasSession) { return (
- {ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"} + {ticketRef ? `Ticket #${ticketRef}` : "Chat"} Offline @@ -777,8 +625,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - // Versão minimizada (chip compacto igual web) - // pointer-events-none no container para que apenas o botao seja clicavel + // Minimizado if (isMinimized) { return (
@@ -788,11 +635,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { > - Ticket #{ticketRef ?? ticketInfo?.ref} + Ticket #{ticketRef} - {/* Badge de mensagens não lidas */} {unreadCount > 0 && ( {unreadCount > 9 ? "9+" : unreadCount} @@ -803,9 +649,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } + // Expandido return ( -
- {/* Header - arrastavel */} +
+ {/* Header */}
- {(ticketRef || ticketInfo) && ( -

- Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"} -

- )} +

+ Ticket #{ticketRef} - Suporte +

+ {machineSessions.length > 1 && ( + + )} @@ -859,14 +718,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { Nenhuma mensagem ainda

- O agente iniciará a conversa em breve + O agente iniciara a conversa em breve

) : (
{messages.map((msg) => { - // No desktop: isFromMachine=true significa mensagem do cliente (maquina) - // Layout igual à web: cliente à esquerda, agente à direita const isAgent = !msg.isFromMachine const bodyText = msg.body.trim() const shouldShowBody = @@ -881,62 +738,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
)} -
{ - if (el) { - messageElementsRef.current.set(msg.id, el) - } else { - messageElementsRef.current.delete(msg.id) - } - }} - className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`} - > - {/* Avatar */}
{ + if (el) { + messageElementsRef.current.set(msg.id, el) + } else { + messageElementsRef.current.delete(msg.id) + } + }} + className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`} > - {isAgent ? : } -
- - {/* Bubble */} -
- {!isAgent && ( -

- {msg.authorName} -

- )} - {shouldShowBody &&

{msg.body}

} - {/* Anexos */} - {msg.attachments && msg.attachments.length > 0 && ( -
- {msg.attachments.map((att) => ( - - ))} -
- )} -

- {formatTime(msg.createdAt)} -

+ {isAgent ? : } +
+ + {/* Bubble */} +
+ {!isAgent && ( +

+ {msg.authorName} +

+ )} + {shouldShowBody &&

{msg.body}

} + {/* Anexos */} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att) => ( + + ))} +
+ )} +

+ {formatTime(msg.createdAt)} +

+
-
) })}
@@ -978,6 +840,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { @@ -998,7 +861,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { onClick={handleAttach} disabled={isUploading || isSending} className="flex size-9 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50" - title="Anexar arquivo" + aria-label="Anexar arquivo" > {isUploading ? ( @@ -1010,6 +873,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { onClick={handleSend} disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending} className="flex size-9 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50" + aria-label="Enviar mensagem" > {isSending ? ( diff --git a/apps/desktop/src/chat/ConvexMachineProvider.tsx b/apps/desktop/src/chat/ConvexMachineProvider.tsx new file mode 100644 index 0000000..6793d58 --- /dev/null +++ b/apps/desktop/src/chat/ConvexMachineProvider.tsx @@ -0,0 +1,146 @@ +/** + * 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 +} + +const ConvexMachineContext = createContext(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({ + token: null, + apiBaseUrl: null, + isLoading: true, + error: null, + }) + + const [client, setClient] = useState(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 ( + + {children} + + ) +} diff --git a/apps/desktop/src/chat/index.tsx b/apps/desktop/src/chat/index.tsx index 02e7f13..db123c0 100644 --- a/apps/desktop/src/chat/index.tsx +++ b/apps/desktop/src/chat/index.tsx @@ -1,21 +1,65 @@ +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 ( +
+
+ Erro: {error} +
+
+ ) + } - if (!ticketId) { return ( -
-

Erro: ticketId não fornecido

+
+
+ + Conectando... +
) } - return + // Modo hub - lista de todas as sessoes + if (isHub || !ticketId) { + return ( + + + + ) + } + + // Modo chat - conversa de um ticket especifico + return ( + + + + ) +} + +export function ChatApp() { + return ( + + + + ) } export { ChatWidget } +export { ChatHubWidget } export * from "./types" diff --git a/apps/desktop/src/chat/useConvexMachineQueries.ts b/apps/desktop/src/chat/useConvexMachineQueries.ts new file mode 100644 index 0000000..ac68253 --- /dev/null +++ b/apps/desktop/src/chat/useConvexMachineQueries.ts @@ -0,0 +1,206 @@ +/** + * 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, + }) + } +} diff --git a/apps/desktop/src/components/DeactivationScreen.tsx b/apps/desktop/src/components/DeactivationScreen.tsx index 972a0ea..4c08067 100644 --- a/apps/desktop/src/components/DeactivationScreen.tsx +++ b/apps/desktop/src/components/DeactivationScreen.tsx @@ -1,23 +1,36 @@ -import { ShieldAlert, Mail } from "lucide-react" +import { ShieldAlert, Mail, RefreshCw } from "lucide-react" +import { useState } from "react" + +type DeactivationScreenProps = { + companyName?: string | null + onRetry?: () => Promise | 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) + } + } -export function DeactivationScreen({ companyName }: { companyName?: string | null }) { return ( -
+
Acesso bloqueado -

Dispositivo desativada

+

Dispositivo desativado

- Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o + Este dispositivo foi desativado temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o envio de informações ficam indisponíveis.

- {companyName ? ( - - {companyName} - - ) : null}
@@ -29,12 +42,25 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
- - Falar com o suporte - +
+ + Falar com o suporte + + {onRetry && ( + + )} +
diff --git a/apps/desktop/src/components/MachineStateMonitor.tsx b/apps/desktop/src/components/MachineStateMonitor.tsx new file mode 100644 index 0000000..69b8b80 --- /dev/null +++ b/apps/desktop/src/components/MachineStateMonitor.tsx @@ -0,0 +1,103 @@ +/** + * 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(null) + const previousHasValidToken = useRef(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 ( + + + + ) +} diff --git a/apps/desktop/src/convex/_generated/api.d.ts b/apps/desktop/src/convex/_generated/api.d.ts new file mode 100644 index 0000000..75fbfcb --- /dev/null +++ b/apps/desktop/src/convex/_generated/api.d.ts @@ -0,0 +1,121 @@ +/* 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 +>; + +/** + * 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 +>; + +export declare const components: {}; diff --git a/apps/desktop/src/convex/_generated/api.js b/apps/desktop/src/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/apps/desktop/src/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* 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(); diff --git a/apps/desktop/src/convex/_generated/dataModel.d.ts b/apps/desktop/src/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..8541f31 --- /dev/null +++ b/apps/desktop/src/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* 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; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = 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 = + GenericId; + +/** + * 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; diff --git a/apps/desktop/src/convex/_generated/server.d.ts b/apps/desktop/src/convex/_generated/server.d.ts new file mode 100644 index 0000000..bec05e6 --- /dev/null +++ b/apps/desktop/src/convex/_generated/server.d.ts @@ -0,0 +1,143 @@ +/* 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; diff --git a/apps/desktop/src/convex/_generated/server.js b/apps/desktop/src/convex/_generated/server.js new file mode 100644 index 0000000..bf3d25a --- /dev/null +++ b/apps/desktop/src/convex/_generated/server.js @@ -0,0 +1,93 @@ +/* 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; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index fe95677..3004e1c 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -6,12 +6,21 @@ 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 @@ -304,7 +313,7 @@ function App() { const [token, setToken] = useState(null) const [config, setConfig] = useState(null) const [profile, setProfile] = useState(null) - const [logoSrc, setLogoSrc] = useState(() => `${appUrl}/logo-raven.png`) + const [logoSrc, setLogoSrc] = useState("/logo-raven.png") const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [status, setStatus] = useState(null) @@ -321,6 +330,9 @@ function App() { const selfHealPromiseRef = useRef | null>(null) const lastHealAtRef = useRef(0) + // Cliente Convex para monitoramento em tempo real do estado da maquina + const [convexClient, setConvexClient] = useState(null) + const [provisioningCode, setProvisioningCode] = useState("") const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) const [companyName, setCompanyName] = useState("") @@ -410,8 +422,15 @@ 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", err) + console.error("Falha ao reiniciar heartbeat/chat", err) } return nextConfig @@ -586,8 +605,15 @@ 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 em segundo plano", err) + console.error("Falha ao iniciar heartbeat/chat em segundo plano", err) } const payload = await res.clone().json().catch(() => null) if (payload && typeof payload === "object" && "machine" in payload) { @@ -679,6 +705,88 @@ 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 @@ -1249,6 +1357,10 @@ 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 @@ -1308,7 +1420,6 @@ 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 } @@ -1316,14 +1427,8 @@ 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 @@ -1373,7 +1478,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]) + }, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive]) async function reprovision() { if (!store) return @@ -1478,17 +1583,28 @@ 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]) + }, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive]) // 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. - if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) { + // IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real + if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) { return (
+ {/* Monitor de estado da máquina - deve rodar mesmo durante loading */} + {token && config?.machineId && convexClient && ( + + )}

Abrindo plataforma da Rever…

@@ -1498,11 +1614,31 @@ const resolvedAppUrl = useMemo(() => { ) } + // Monitor sempre ativo quando há token e machineId + const machineMonitor = token && config?.machineId && convexClient ? ( + + ) : null + + // Tela de desativação (renderizada separadamente para evitar container com fundo claro) + if (token && !isMachineActive) { + return ( + <> + {machineMonitor} + + + ) + } + return (
- {token && !isMachineActive ? ( - - ) : ( + {/* Monitor de estado da maquina em tempo real via Convex */} + {machineMonitor}
{ alt="Logotipo Raven" width={160} height={160} - className="h-14 w-auto md:h-16" + className="h-16 w-auto md:h-20" onError={() => { if (logoFallbackRef.current) return logoFallbackRef.current = true - setLogoSrc(`${appUrl}/raven.png`) + setLogoSrc(`${appUrl}/logo-raven.png`) }} />
- Raven - Sistema de chamados + Raven +
+ + Plataforma de + + + Chamados + +
@@ -1723,8 +1866,6 @@ const resolvedAppUrl = useMemo(() => {
)}
- )} -
) } diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0a3d9c7..5e0a227 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -19,7 +19,13 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "jsx": "react-jsx", - "types": ["vite/client"] + "types": ["vite/client"], + + /* Paths */ + "baseUrl": ".", + "paths": { + "@convex/_generated/*": ["./src/convex/_generated/*"] + } }, "include": ["src"] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9c1d6d2..1f22f44 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { resolve } from "path"; const host = process.env.TAURI_DEV_HOST; @@ -7,6 +8,13 @@ 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 diff --git a/bun.lock b/bun.lock index 013a138..4eddfda 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@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", @@ -114,6 +115,7 @@ "@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", @@ -512,6 +514,8 @@ "@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=="], @@ -2332,6 +2336,8 @@ "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=="], @@ -2466,6 +2472,8 @@ "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=="], @@ -2597,5 +2605,55 @@ "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=="], } } diff --git a/convex/automations.ts b/convex/automations.ts index d6abc56..59216b2 100644 --- a/convex/automations.ts +++ b/convex/automations.ts @@ -14,7 +14,7 @@ import { } from "./automationsEngine" import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates" import { TICKET_FORM_CONFIG } from "./ticketForms.config" -import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail" +import type { AutomationEmailProps } from "./reactEmail" import { buildBaseUrl } from "./url" import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist" @@ -988,19 +988,38 @@ async function applyActions( ctaLabel, ctaUrl, } - const html = await renderAutomationEmailHtml(emailProps) await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, { to, subject, - html, + 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, + }, }) 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(), }, }) } diff --git a/convex/checklistTemplates.ts b/convex/checklistTemplates.ts index 34c75cc..c9329b5 100644 --- a/convex/checklistTemplates.ts +++ b/convex/checklistTemplates.ts @@ -14,17 +14,37 @@ 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: Array<{ id?: string; text: string; required?: boolean }>, + raw: RawTemplateItem[], 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() - const items: Array<{ id: string; text: string; required?: boolean }> = [] + const items: NormalizedTemplateItem[] = [] for (const entry of raw) { const id = String(entry.id ?? "").trim() || generateId() @@ -41,8 +61,25 @@ 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, required }) + items.push({ + id, + text, + description, + type: itemType, + options: itemOptions, + required, + }) } return items @@ -57,6 +94,9 @@ 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), @@ -164,6 +204,9 @@ 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()), }), ), @@ -216,6 +259,9 @@ 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()), }), ), @@ -279,3 +325,52 @@ 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 } + }, +}) diff --git a/convex/companySlas.ts b/convex/companySlas.ts new file mode 100644 index 0000000..f7412fe --- /dev/null +++ b/convex/companySlas.ts @@ -0,0 +1,273 @@ +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() + 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> = 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, + } +} diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 94109ef..0c700ae 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -168,7 +168,40 @@ export const startSession = mutation({ createdAt: now, }) - return { sessionId, isNew: true } + // 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 } }, }) @@ -225,7 +258,60 @@ export const endSession = mutation({ createdAt: now, }) - return { ok: true } + // 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 } }, }) @@ -417,8 +503,14 @@ 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( - sessions.map(async (session) => { + validSessions.map(async (session) => { const ticket = await ctx.db.get(session.ticketId) return { sessionId: session._id, @@ -520,13 +612,18 @@ export const checkMachineUpdates = query({ const { machine } = await validateMachineToken(ctx, args.machineToken) // Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak) - const sessions = await ctx.db + const rawSessions = 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, @@ -763,27 +860,40 @@ export const getTicketChatHistory = query({ // Timeout de maquina offline: 5 minutos sem heartbeat const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 -// 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 +// 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) export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { - console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") + console.log("cron: autoEndInactiveSessions iniciado") 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 activeSessions = await ctx.db + const rawActiveSessions = 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 = {} for (const session of activeSessions) { checkedCount++ @@ -812,6 +922,36 @@ 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 } @@ -819,7 +959,7 @@ export const autoEndInactiveSessions = mutation({ const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff - // Se máquina está online, manter sessão ativa + // Se máquina está online e chat está ativo, manter sessão if (machineIsOnline) { continue } @@ -849,10 +989,40 @@ export const autoEndInactiveSessions = mutation({ }) endedCount++ + reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1 } - console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) - return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } + 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 } }, }) diff --git a/convex/machineSoftware.ts b/convex/machineSoftware.ts new file mode 100644 index 0000000..1615391 --- /dev/null +++ b/convex/machineSoftware.ts @@ -0,0 +1,276 @@ +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() + 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, + } + }, +}) diff --git a/convex/machines.ts b/convex/machines.ts index 3428759..8a688a6 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -1,6 +1,6 @@ // ci: trigger convex functions deploy (no-op) import { mutation, query } from "./_generated/server" -import { api } from "./_generated/api" +import { internal, api } from "./_generated/api" import { paginationOptsValidator } from "convex/server" import { ConvexError, v, Infer } from "convex/values" import { sha256 } from "@noble/hashes/sha2.js" @@ -331,9 +331,59 @@ async function getMachineLastHeartbeat( return hb?.lastHeartbeatAt ?? fallback ?? null } -// 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"]) +// 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 +} function mergeInventory(current: JsonRecord | null | undefined, patch: Record): JsonRecord { const sanitizedPatch = sanitizeRecord(patch) @@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record) @@ -393,9 +444,20 @@ function ensureString(value: unknown): string | null { function sanitizeInventoryPayload(value: unknown): JsonRecord | null { const record = sanitizeRecord(value) if (!record) return null - for (const blocked of INVENTORY_BLOCKLIST) { - delete record[blocked] + + // 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"] + } } + + // Deletar apenas software (extended já foi processado acima) + delete record["software"] + return record } @@ -956,10 +1018,13 @@ export const heartbeat = mutation({ } } - const sanitizedInventory = sanitizeInventoryPayload(args.inventory) + // Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata) + const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record | undefined) + const sanitizedInventory = sanitizeInventoryPayload(rawInventory) 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 @@ -1010,6 +1075,34 @@ 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)["software"] + if (Array.isArray(softwareArray) && softwareArray.length > 0) { + const validSoftware = softwareArray + .filter((item): item is Record => 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, @@ -2317,6 +2410,44 @@ 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 diff --git a/convex/migrations.ts b/convex/migrations.ts index 9ea35e6..6127128 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -1043,3 +1043,81 @@ 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: "

Responsável atualizado:..." + */ +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 = "

Responsável atualizado:" + 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() + 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, + } + }, +}) diff --git a/convex/queues.ts b/convex/queues.ts index 2f70228..78bb6a9 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -154,10 +154,17 @@ 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") { - inProgress += 1; + // "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; + } } else if (status === "PAUSED") { paused += 1; } diff --git a/convex/reactEmail.tsx b/convex/reactEmail.tsx index b81fade..7ec9382 100644 --- a/convex/reactEmail.tsx +++ b/convex/reactEmail.tsx @@ -3,8 +3,31 @@ 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 } +export type { + AutomationEmailProps, + SimpleNotificationEmailProps, + InviteEmailProps, + PasswordResetEmailProps, + NewLoginEmailProps, + SlaWarningEmailProps, + SlaBreachedEmailProps, + TicketCreatedEmailProps, + TicketResolvedEmailProps, + TicketAssignedEmailProps, + TicketStatusEmailProps, + TicketCommentEmailProps, +} export async function renderAutomationEmailHtml(props: AutomationEmailProps) { return render(, { pretty: false }) @@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) { export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) { return render(, { pretty: false }) } + +export async function renderInviteEmailHtml(props: InviteEmailProps) { + return render(, { pretty: false }) +} + +export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) { + return render(, { pretty: false }) +} + +export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) { + return render(, { pretty: false }) +} + +export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) { + return render(, { pretty: false }) +} + +export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) { + return render(, { pretty: false }) +} diff --git a/convex/reports.ts b/convex/reports.ts index 5c18044..563a082 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -161,11 +161,8 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks } } -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 logDashboardProgress(_processed: number, _tenantId: string) { + // Log de progresso removido para reduzir ruido no console } function mapToChronologicalSeries(map: Map) { @@ -2406,18 +2403,20 @@ export const companyOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), - companyId: v.id("companies"), + companyId: v.optional(v.id("companies")), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); - if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { - throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); - } + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); - const company = await ctx.db.get(companyId); - if (!company || company.tenantId !== tenantId) { - throw new ConvexError("Empresa não encontrada"); + // 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 normalizedRange = (range ?? "30d").toLowerCase(); @@ -2426,20 +2425,35 @@ export const companyOverview = query({ const startMs = now - rangeDays * ONE_DAY_MS; // Limita consultas para evitar OOM em empresas muito grandes - const tickets = await ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(2000); + 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 machines = await ctx.db - .query("machines") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(1000); + 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 users = await ctx.db - .query("users") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(500); + 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 statusCounts = {} as Record; const priorityCounts = {} as Record; @@ -2534,11 +2548,13 @@ export const companyOverview = query({ }); return { - company: { - id: company._id, - name: company.name, - isAvulso: company.isAvulso ?? false, - }, + company: company + ? { + id: company._id, + name: company.name, + isAvulso: company.isAvulso ?? false, + } + : null, rangeDays, generatedAt: now, tickets: { diff --git a/convex/schema.ts b/convex/schema.ts index 3402484..9e3502a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -82,6 +82,7 @@ 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()), @@ -199,7 +200,11 @@ 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({ @@ -314,10 +319,15 @@ 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()), @@ -478,6 +488,7 @@ 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()), }) @@ -587,6 +598,29 @@ 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(), @@ -658,6 +692,9 @@ 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()), }) ), @@ -788,6 +825,25 @@ 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"), diff --git a/convex/slas.ts b/convex/slas.ts index 32ab0a5..27f6645 100644 --- a/convex/slas.ts +++ b/convex/slas.ts @@ -9,6 +9,26 @@ 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">) { @@ -35,7 +55,11 @@ 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"], })); }, }); @@ -47,9 +71,14 @@ 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, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + handler: async (ctx, args) => { + const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args; await requireAdmin(ctx, actorId, tenantId); const trimmed = normalizeName(name); if (trimmed.length < 2) { @@ -68,7 +97,11 @@ export const create = mutation({ name: trimmed, description, timeToFirstResponse, + responseMode: normalizeMode(responseMode), timeToResolution, + solutionMode: normalizeMode(solutionMode), + alertThreshold: normalizeThreshold(alertThreshold), + pauseStatuses: normalizePauseStatuses(pauseStatuses), }); return id; }, @@ -82,9 +115,14 @@ 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, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + handler: async (ctx, args) => { + const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args; await requireAdmin(ctx, actorId, tenantId); const policy = await ctx.db.get(policyId); if (!policy || policy.tenantId !== tenantId) { @@ -106,7 +144,11 @@ export const update = mutation({ name: trimmed, description, timeToFirstResponse, + responseMode: normalizeMode(responseMode), timeToResolution, + solutionMode: normalizeMode(solutionMode), + alertThreshold: normalizeThreshold(alertThreshold), + pauseStatuses: normalizePauseStatuses(pauseStatuses), }); }, }); diff --git a/convex/ticketChecklist.ts b/convex/ticketChecklist.ts index efef60b..31bbb8d 100644 --- a/convex/ticketChecklist.ts +++ b/convex/ticketChecklist.ts @@ -1,21 +1,38 @@ 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"> - items: Array<{ id: string; text: string; required?: boolean }> + description?: string + items: TicketChecklistTemplateItem[] } export function normalizeChecklistText(input: string) { @@ -53,13 +70,18 @@ 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, }) diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 9d1d405..50f2246 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -8,6 +8,45 @@ 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 + 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") } @@ -281,25 +320,109 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: } } -export const sendPublicCommentEmail = action({ +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, ticketId, reference, subject }) => { + handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => { + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + + const priorityLabels: Record = { + 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 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, }) @@ -311,22 +434,45 @@ 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, ticketId, reference, subject }) => { + 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 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, }) @@ -339,9 +485,23 @@ export const sendAutomationEmail = action({ args: { to: v.array(v.string()), subject: v.string(), - html: 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(), + }), }, - handler: async (_ctx, { to, subject, html }) => { + handler: async (_ctx, { to, subject, emailProps }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping automation email") @@ -357,10 +517,45 @@ 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) { - await sendSmtpMail(smtp, recipient, subject, html) + 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}`) + } } - return { ok: true, sent: recipients.length } + 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 } }, }) diff --git a/convex/tickets.ts b/convex/tickets.ts index e2c5602..27922cc 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record = { 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}$/; @@ -272,25 +273,74 @@ async function resolveTicketSlaSnapshot( ctx: AnyCtx, tenantId: string, category: Doc<"ticketCategories"> | null, - priority: string + priority: string, + companyId?: Id<"companies"> | null ): Promise { if (!category) { return null; } const normalizedPriority = priority.trim().toUpperCase(); - const rule = - (await ctx.db - .query("categorySlaSettings") - .withIndex("by_tenant_category_priority", (q) => - q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) + + // 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) ) - .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(); + + // 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()); + } + if (!rule) { return null; } @@ -872,23 +922,6 @@ 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) => `

${escapeHtml(line)}

`).join("") - : `

`; - return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${reasonHtml}`; -} - function truncateSubject(subject: string) { if (subject.length <= 60) return subject return `${subject.slice(0, 57)}…` @@ -2098,10 +2131,15 @@ 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, @@ -2337,7 +2375,7 @@ export const create = mutation({ avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } - const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) + // Resolve a empresa primeiro para poder verificar SLA específico let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null if (!companyDoc && machineDoc?.companyId) { const candidateCompany = await ctx.db.get(machineDoc.companyId) @@ -2349,6 +2387,8 @@ 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 ?? []) { @@ -2456,6 +2496,28 @@ 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, @@ -2647,6 +2709,49 @@ 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"), @@ -2700,6 +2805,34 @@ 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"), @@ -2851,15 +2984,19 @@ 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 snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email + const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined + const snapshotEmail = requesterSnapshot?.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, }) } } @@ -3090,7 +3227,18 @@ export async function resolveTicketHandler( throw new ConvexError("Chamado vinculado não encontrado") } - const reopenDays = resolveReopenWindowDays(reopenWindowDays) + // 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 reopenDeadline = computeReopenDeadline(now, reopenDays) const normalizedStatus = "RESOLVED" const relatedIdList = Array.from( @@ -3127,16 +3275,21 @@ export async function resolveTicketHandler( // Notificação por e-mail: encerramento do chamado try { - const requesterDoc = await ctx.db.get(ticketDoc.requesterId) - const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null + 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 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, }) } } @@ -3373,38 +3526,6 @@ 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, - }) - } }, }); @@ -3734,6 +3855,8 @@ 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 @@ -3743,10 +3866,15 @@ export const postChatMessage = mutation({ .first() if (activeSession) { - await ctx.db.patch(activeSession._id, { - unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1, - lastActivityAt: now, - }) + // 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 + }) + } } } diff --git a/convex/users.ts b/convex/users.ts index e435da6..ab24ef5 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -279,6 +279,86 @@ 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 }) => { diff --git a/docs/DEPLOY-MANUAL.md b/docs/DEPLOY-MANUAL.md index 4487d5c..bb9aa59 100644 --- a/docs/DEPLOY-MANUAL.md +++ b/docs/DEPLOY-MANUAL.md @@ -1,11 +1,11 @@ # Deploy Manual via VPS ## Acesso rápido -- Host: 31.220.78.20 +- Host: 154.12.253.40 - Usuário: root - Caminho do projeto: /srv/apps/sistema - Chave SSH (local): ./codex_ed25519 (chmod 600) -- Login: `ssh -i ./codex_ed25519 root@31.220.78.20` +- Login: `ssh -i ./codex_ed25519 root@154.12.253.40` ## Passo a passo resumido 1. Conectar na VPS usando o comando acima. diff --git a/docs/DEV.md b/docs/DEV.md index 2ca05f7..e0da9da 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -1,4 +1,4 @@ -# Guia de Desenvolvimento — 18/10/2025 +# Guia de Desenvolvimento — 18/12/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.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback. +- **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. - **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.8`, com React 19 e o App Router completo. +- Mantemos o projeto em `next@16.0.10`, 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/DEPLOY-RUNBOOK.md`. -- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. +- **Deploy (Swarm)**: veja `docs/OPERATIONS.md`. +- **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.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. diff --git a/docs/FORGEJO-CI-CD.md b/docs/FORGEJO-CI-CD.md new file mode 100644 index 0000000..fb1cc56 --- /dev/null +++ b/docs/FORGEJO-CI-CD.md @@ -0,0 +1,296 @@ +# 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) diff --git a/docs/LOCAL-DEV.md b/docs/LOCAL-DEV.md new file mode 100644 index 0000000..00b4e34 --- /dev/null +++ b/docs/LOCAL-DEV.md @@ -0,0 +1,166 @@ +# 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` diff --git a/docs/README.md b/docs/README.md index 85c2a80..f9e7433 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ 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): diff --git a/docs/RETENTION-HEALTH.md b/docs/RETENTION-HEALTH.md index 4bcd947..4f8d198 100644 --- a/docs/RETENTION-HEALTH.md +++ b/docs/RETENTION-HEALTH.md @@ -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 (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets. +- Base quente: Prisma (PostgreSQL) 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 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 do Convex 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. diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..02452c9 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,252 @@ +# 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` diff --git a/docs/SMTP.md b/docs/SMTP.md index e8e3ee2..900df73 100644 --- a/docs/SMTP.md +++ b/docs/SMTP.md @@ -15,14 +15,17 @@ 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_HOST=smtp.c.inova.com.br +SMTP_ADDRESS=smtp.c.inova.com.br SMTP_PORT=587 -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 +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 ``` ## Exemplo de Uso (Nodemailer) diff --git a/docs/alteracoes-producao-2025-12-18.md b/docs/alteracoes-producao-2025-12-18.md new file mode 100644 index 0000000..68a0371 --- /dev/null +++ b/docs/alteracoes-producao-2025-12-18.md @@ -0,0 +1,54 @@ +# 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 pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg16_20251218215925.dump +docker exec -u postgres 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 psql -c "DROP DATABASE IF EXISTS sistema_chamados;" +docker exec -u postgres psql -c "CREATE DATABASE sistema_chamados OWNER sistema;" +docker cp /root/pg-backups/sistema_chamados_pg16_20251218215925.dump :/tmp/sistema_chamados_restore.dump +docker exec -u postgres 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. diff --git a/docs/alteracoes-producao-2025-12-19.md b/docs/alteracoes-producao-2025-12-19.md new file mode 100644 index 0000000..7e135aa --- /dev/null +++ b/docs/alteracoes-producao-2025-12-19.md @@ -0,0 +1,32 @@ +# 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). diff --git a/docs/convex-export-worker-loop.md b/docs/convex-export-worker-loop.md index a9f3f5b..5812685 100644 --- a/docs/convex-export-worker-loop.md +++ b/docs/convex-export-worker-loop.md @@ -112,7 +112,39 @@ Critérios de sucesso: --- -## 6. Referências rápidas +## 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 - Volume Convex: `sistema_convex_data` - Banco: `/convex/data/db.sqlite3` @@ -122,4 +154,4 @@ Critérios de sucesso: --- -Última revisão: **18/11/2025** — sanado por remoção dos registros incompatíveis e rerun bem-sucedido do export `gg20vw5b479d9a2jprjpe3pxg57vk9wa`. +Última revisão: **18/12/2025** — limpeza da versão legada de `liveChatSessions` (`pd71bvfbxx7th3npdj519hcf3s7xbe2j`) e restart do Convex. diff --git a/docs/diagnostico-chat-desktop-2025-12-19.md b/docs/diagnostico-chat-desktop-2025-12-19.md new file mode 100644 index 0000000..cca63d4 --- /dev/null +++ b/docs/diagnostico-chat-desktop-2025-12-19.md @@ -0,0 +1,51 @@ +# 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`. diff --git a/emails/_components/ticket-card.tsx b/emails/_components/ticket-card.tsx index 31ac291..d623acc 100644 --- a/emails/_components/ticket-card.tsx +++ b/emails/_components/ticket-card.tsx @@ -14,6 +14,18 @@ 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 ( ) } + +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 ( +
+ + + + + + + + + +
+ + Chamado #{ticketNumber} + + + {ticketTitle} + +
+ + + {status ? ( + + + + + ) : null} + {priority ? ( + + + + + ) : null} + {categoryLabel ? ( + + + + + ) : null} + {companyName ? ( + + + + + ) : null} + {requesterName ? ( + + + + + ) : null} + {assigneeName ? ( + + + + + ) : null} + +
+ Status + {statusBadge(status)}
+ Prioridade + {priorityBadge(priority)}
+ Categoria + {categoryLabel}
+ Empresa + {companyName}
+ Solicitante + {requesterName}
+ Responsavel + {assigneeName}
+
+
+ ) +} diff --git a/emails/automation-email.tsx b/emails/automation-email.tsx index ebc900c..2555f3c 100644 --- a/emails/automation-email.tsx +++ b/emails/automation-email.tsx @@ -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 { TicketCard, type TicketCardData } from "./_components/ticket-card" +import { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card" import { normalizeTextToParagraphs } from "./_components/utils" export type AutomationEmailProps = { @@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) { )} - +
+
+ +
+ + + Se o botao nao funcionar, copie e cole esta URL no navegador: +
+ + {inviteUrl} + +
+ + + Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca. + + + ) +} + +InviteEmail.PreviewProps = { + inviterName: "Renan Oliveira", + roleName: "Agente", + companyName: "Paulicon", + inviteUrl: "https://raven.rever.com.br/invite/abc123def456", +} satisfies InviteEmailProps diff --git a/emails/new-login-email.tsx b/emails/new-login-email.tsx new file mode 100644 index 0000000..6e2ef20 --- /dev/null +++ b/emails/new-login-email.tsx @@ -0,0 +1,150 @@ +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 ( + +
+
+ 🔒 +
+
+ + + Novo acesso detectado + + + + Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail. + + +
+ + + + + + + + + {location ? ( + + + + ) : null} + + + + +
+ + + + + + + +
+ Data/Hora + + {formatDate(loginAt)} +
+
+ + + + + + + +
+ Endereco IP + + {ipAddress} +
+
+ + + + + + + +
+ Localizacao + + {location} +
+
+ + + + + + + +
+ Dispositivo + + {userAgent} +
+
+
+ +
+ + + Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente. + +
+ ) +} + +NewLoginEmail.PreviewProps = { + loginAt: new Date().toISOString(), + ipAddress: "192.168.1.100", + userAgent: "Chrome 120.0 / Windows 11", + location: "Sao Paulo, SP, Brasil", +} satisfies NewLoginEmailProps diff --git a/emails/password-reset-email.tsx b/emails/password-reset-email.tsx new file mode 100644 index 0000000..b0db729 --- /dev/null +++ b/emails/password-reset-email.tsx @@ -0,0 +1,81 @@ +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 ( + +
+
+ 🔒 +
+
+ + + Redefinir senha + + + + Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha. + + +
+ +
+ +
+ + + Se o botao nao funcionar, copie e cole esta URL no navegador: +
+ + {resetUrl} + +
+ + + Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca. + +
+ ) +} + +PasswordResetEmail.PreviewProps = { + resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456", + expiresIn: "1 hora", +} satisfies PasswordResetEmailProps diff --git a/emails/sla-breached-email.tsx b/emails/sla-breached-email.tsx new file mode 100644 index 0000000..a30f916 --- /dev/null +++ b/emails/sla-breached-email.tsx @@ -0,0 +1,151 @@ +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 ( + +
+
+ 🚨 +
+
+ + + SLA estourado + + + + O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata. + + +
+ + + + + + + + + + + + +
+ + + + + + + +
+ Chamado + + #{ticketNumber} +
+
+ + + + + + + +
+ Titulo + + {ticketTitle} +
+
+ + + + + + + +
+ Estourado em + + {formatDate(breachedAt)} +
+
+
+ +
+ +
+ +
+ + + Este chamado deve ser tratado com prioridade maxima. + +
+ ) +} + +SlaBreachedEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + breachedAt: new Date().toISOString(), + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies SlaBreachedEmailProps diff --git a/emails/sla-warning-email.tsx b/emails/sla-warning-email.tsx new file mode 100644 index 0000000..c9d66ee --- /dev/null +++ b/emails/sla-warning-email.tsx @@ -0,0 +1,139 @@ +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 ( + +
+
+ ⚠ +
+
+ + + Alerta de SLA + + + + O chamado abaixo esta proximo de estourar o tempo de atendimento acordado. + + +
+ + + + + + + + + + + + +
+ + + + + + + +
+ Chamado + + #{ticketNumber} +
+
+ + + + + + + +
+ Titulo + + {ticketTitle} +
+
+ + + + + + + +
+ Tempo restante + + {timeRemaining} +
+
+
+ +
+ +
+ +
+ + + Acesse o sistema para mais detalhes e acompanhe o status do chamado. + +
+ ) +} + +SlaWarningEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + timeRemaining: "45 minutos", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies SlaWarningEmailProps diff --git a/emails/ticket-assigned-email.tsx b/emails/ticket-assigned-email.tsx new file mode 100644 index 0000000..a97ac23 --- /dev/null +++ b/emails/ticket-assigned-email.tsx @@ -0,0 +1,82 @@ +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 ( + +
+
+ 👤 +
+
+ + + Chamado atribuido + + + + O chamado foi atribuido a {assigneeName}. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +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 diff --git a/emails/ticket-comment-email.tsx b/emails/ticket-comment-email.tsx new file mode 100644 index 0000000..c3cebc2 --- /dev/null +++ b/emails/ticket-comment-email.tsx @@ -0,0 +1,113 @@ +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 ( + +
+
+ 💬 +
+
+ + + Novo comentario + + + + {commenterName} comentou no chamado #{ticketNumber}. + + +
+ + + + + + + + + +
+ + Chamado #{ticketNumber} + + + {ticketTitle} + +
+ + Comentario + + + {commentPreview} + +
+
+ +
+ +
+ +
+ + + Clique no botao acima para ver o comentario completo e responder. + +
+ ) +} + +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 diff --git a/emails/ticket-created-email.tsx b/emails/ticket-created-email.tsx new file mode 100644 index 0000000..ff187c8 --- /dev/null +++ b/emails/ticket-created-email.tsx @@ -0,0 +1,80 @@ +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 ( + +
+
+ ✅ +
+
+ + + Chamado criado + + + + Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +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 diff --git a/emails/ticket-resolved-email.tsx b/emails/ticket-resolved-email.tsx new file mode 100644 index 0000000..a1e84e3 --- /dev/null +++ b/emails/ticket-resolved-email.tsx @@ -0,0 +1,121 @@ +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 ( + +
+
+ 🎉 +
+
+ + + Chamado resolvido + + + + Seu chamado foi marcado como resolvido. Confira os detalhes abaixo. + + + + + {resolution ? ( +
+ + Resolucao + + + {resolution} + +
+ ) : null} + +
+ + {ratingUrl ? ( + + ) : null} +
+ +
+ + + Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar. + +
+ ) +} + +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 diff --git a/emails/ticket-status-email.tsx b/emails/ticket-status-email.tsx new file mode 100644 index 0000000..2d50d45 --- /dev/null +++ b/emails/ticket-status-email.tsx @@ -0,0 +1,85 @@ +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 ( + +
+
+ 🔄 +
+
+ + + Status atualizado + + + + O status do seu chamado foi alterado de {formatStatus(previousStatus)} para {formatStatus(newStatus)}. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +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 diff --git a/eslint.config.mjs b/eslint.config.mjs index e765ea5..86d1e3c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ const eslintConfig = [ "referência/**", "next-env.d.ts", "convex/_generated/**", + "apps/desktop/src/convex/_generated/**", ], }, { diff --git a/forgejo/setup-runner.sh b/forgejo/setup-runner.sh new file mode 100644 index 0000000..05f3f23 --- /dev/null +++ b/forgejo/setup-runner.sh @@ -0,0 +1,113 @@ +#!/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" diff --git a/forgejo/stack.yml b/forgejo/stack.yml new file mode 100644 index 0000000..375ee29 --- /dev/null +++ b/forgejo/stack.yml @@ -0,0 +1,89 @@ +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 diff --git a/package.json b/package.json index 186a9fa..e62d82b 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@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", diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100644 index 0000000..703411f --- /dev/null +++ b/scripts/setup-dev.sh @@ -0,0 +1,252 @@ +#!/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 "" diff --git a/scripts/test-all-emails.tsx b/scripts/test-all-emails.tsx new file mode 100644 index 0000000..ef9929a --- /dev/null +++ b/scripts/test-all-emails.tsx @@ -0,0 +1,188 @@ +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 +} + +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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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) +}) diff --git a/scripts/test-email.ts b/scripts/test-email.ts new file mode 100644 index 0000000..64a375e --- /dev/null +++ b/scripts/test-email.ts @@ -0,0 +1,209 @@ +/** + * 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" ', + 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) diff --git a/scripts/utils/prisma-client.mjs b/scripts/utils/prisma-client.mjs index af2fd9b..515356c 100644 --- a/scripts/utils/prisma-client.mjs +++ b/scripts/utils/prisma-client.mjs @@ -1,63 +1,24 @@ -import path from "node:path" +import pg from "pg" // 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 { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3" +import { PrismaPg } from "@prisma/adapter-pg" -const PROJECT_ROOT = process.cwd() -const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma") - -function resolveFileUrl(url) { - if (!url.startsWith("file:")) { - return url - } - - const filePath = url.slice("file:".length) - - 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") -} +const { Pool } = pg export function createPrismaClient() { - const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL) - process.env.DATABASE_URL = resolvedDatabaseUrl + const databaseUrl = process.env.DATABASE_URL - const adapter = new PrismaBetterSqlite3({ - url: resolvedDatabaseUrl, + if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is required") + } + + const pool = new Pool({ + connectionString: databaseUrl, }) + const adapter = new PrismaPg(pool) + return new PrismaClient({ adapter }) } diff --git a/src/app/api/admin/fix-chat-sessions/route.ts b/src/app/api/admin/fix-chat-sessions/route.ts new file mode 100644 index 0000000..3ed362c --- /dev/null +++ b/src/app/api/admin/fix-chat-sessions/route.ts @@ -0,0 +1,30 @@ +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 } + ) + } +} diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 958513e..6093176 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -10,7 +10,8 @@ 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 { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" +import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" +import { notifyUserInvite } from "@/server/notification/notification-service" const DEFAULT_EXPIRATION_DAYS = 7 const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput @@ -27,6 +28,17 @@ function normalizeRole(input: string | null | undefined): RoleOption { return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" } +const ROLE_LABELS: Record = { + 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") } @@ -213,5 +225,24 @@ 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 }) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index c3c624d..21af431 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -204,7 +204,7 @@ export async function POST(request: Request) { }) const createdDomainUser = await tx.user.upsert({ - where: { email }, + where: { id: createdAuthUser.id }, update: { name, role: userRole, @@ -213,6 +213,7 @@ export async function POST(request: Request) { managerId: managerRecord?.id ?? null, }, create: { + id: createdAuthUser.id, name, email, role: userRole, diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..dd2ed23 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,101 @@ +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 }) + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..2c7fe6e --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,97 @@ +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" }) + } +} diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts index bef5551..2fff5cc 100644 --- a/src/app/api/invites/[token]/route.ts +++ b/src/app/api/invites/[token]/route.ts @@ -157,6 +157,23 @@ export async function POST(request: Request, context: { params: Promise<{ token: }, }) + // Criar registro na tabela User (dominio) com mesmo ID do AuthUser + try { + await prisma.user.upsert({ + where: { id: user.id }, + update: { role: role.toUpperCase() as "ADMIN" | "AGENT" | "COLLABORATOR", name }, + create: { + id: user.id, + tenantId, + email: invite.email, + name, + role: role.toUpperCase() as "ADMIN" | "AGENT" | "COLLABORATOR", + }, + }) + } catch (err) { + console.warn("Falha ao criar User de dominio", err) + } + const updatedInvite = await prisma.authInvite.update({ where: { id: invite.id }, data: { diff --git a/src/app/api/machines/chat/attachments/url/route.ts b/src/app/api/machines/chat/attachments/url/route.ts index d94d458..ed7feb3 100644 --- a/src/app/api/machines/chat/attachments/url/route.ts +++ b/src/app/api/machines/chat/attachments/url/route.ts @@ -4,6 +4,7 @@ import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" const attachmentUrlSchema = z.object({ @@ -87,6 +88,16 @@ export async function POST(request: Request) { return jsonWithCors({ url }, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS, + rateLimitHeaders(rateLimit) + ) + } console.error("[machines.chat.attachments.url] Falha ao obter URL de anexo", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao obter URL de anexo", details }, 500, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) diff --git a/src/app/api/machines/chat/messages/route.ts b/src/app/api/machines/chat/messages/route.ts index e431795..74019d5 100644 --- a/src/app/api/machines/chat/messages/route.ts +++ b/src/app/api/machines/chat/messages/route.ts @@ -4,6 +4,7 @@ import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" import { withRetry } from "@/server/retry" @@ -115,6 +116,15 @@ export async function POST(request: Request) { }) return jsonWithCors(result, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.chat.messages] Falha ao listar mensagens", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao listar mensagens", details }, 500, origin, CORS_METHODS) @@ -159,6 +169,15 @@ export async function POST(request: Request) { ) return jsonWithCors(result, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.chat.messages] Falha ao enviar mensagem", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao enviar mensagem", details }, 500, origin, CORS_METHODS) diff --git a/src/app/api/machines/chat/poll/route.ts b/src/app/api/machines/chat/poll/route.ts index c3e009c..f84bfde 100644 --- a/src/app/api/machines/chat/poll/route.ts +++ b/src/app/api/machines/chat/poll/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" const pollSchema = z.object({ @@ -68,6 +69,16 @@ export async function POST(request: Request) { }) return jsonWithCors(result, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS, + rateLimitHeaders(rateLimit) + ) + } console.error("[machines.chat.poll] Falha ao verificar atualizacoes", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao verificar atualizacoes", details }, 500, origin, CORS_METHODS) diff --git a/src/app/api/machines/chat/read/route.ts b/src/app/api/machines/chat/read/route.ts index d31ba72..fdc6fa0 100644 --- a/src/app/api/machines/chat/read/route.ts +++ b/src/app/api/machines/chat/read/route.ts @@ -4,6 +4,7 @@ import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" const readSchema = z.object({ @@ -69,9 +70,18 @@ export async function POST(request: Request) { }) return jsonWithCors(result, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS, + rateLimitHeaders(rateLimit) + ) + } console.error("[machines.chat.read] Falha ao marcar mensagens como lidas", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao marcar mensagens como lidas", details }, 500, origin, CORS_METHODS) } } - diff --git a/src/app/api/machines/chat/sessions/route.ts b/src/app/api/machines/chat/sessions/route.ts index 431bcc2..0ab0474 100644 --- a/src/app/api/machines/chat/sessions/route.ts +++ b/src/app/api/machines/chat/sessions/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" const sessionsSchema = z.object({ @@ -66,6 +67,16 @@ export async function POST(request: Request) { }) return jsonWithCors({ sessions }, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS, + rateLimitHeaders(rateLimit) + ) + } console.error("[machines.chat.sessions] Falha ao listar sessoes", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao listar sessoes", details }, 500, origin, CORS_METHODS) diff --git a/src/app/api/machines/chat/stream/route.ts b/src/app/api/machines/chat/stream/route.ts index e86a01d..183528a 100644 --- a/src/app/api/machines/chat/stream/route.ts +++ b/src/app/api/machines/chat/stream/route.ts @@ -1,6 +1,7 @@ import { api } from "@/convex/_generated/api" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" import { resolveCorsOrigin } from "@/server/cors" +import { resolveMachineTokenError } from "@/server/machines/token-errors" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -45,9 +46,10 @@ export async function GET(request: Request) { try { await client.query(api.liveChat.checkMachineUpdates, { machineToken: token }) } catch (error) { - const message = error instanceof Error ? error.message : "Token invalido" + const tokenError = resolveMachineTokenError(error) + const message = tokenError?.message ?? (error instanceof Error ? error.message : "Token invalido") return new Response(message, { - status: 401, + status: tokenError?.status ?? 401, headers: { "Access-Control-Allow-Origin": resolvedOrigin, "Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false", @@ -110,6 +112,15 @@ export async function GET(request: Request) { previousState = currentState } } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + sendEvent("error", { code: tokenError.code, message: tokenError.message }) + isAborted = true + clearInterval(pollInterval) + clearInterval(heartbeatInterval) + controller.close() + return + } console.error("[SSE] Poll error:", error) // Enviar erro e fechar conexao sendEvent("error", { message: "Poll failed" }) diff --git a/src/app/api/machines/chat/upload/route.ts b/src/app/api/machines/chat/upload/route.ts index 8a231be..1055c5a 100644 --- a/src/app/api/machines/chat/upload/route.ts +++ b/src/app/api/machines/chat/upload/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" const uploadUrlSchema = z.object({ machineToken: z.string().min(1), @@ -60,6 +61,15 @@ export async function POST(request: Request) { }) return jsonWithCors(result, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.chat.upload] Falha ao gerar URL de upload", error) const details = error instanceof Error ? error.message : String(error) diff --git a/src/app/api/machines/heartbeat/route.ts b/src/app/api/machines/heartbeat/route.ts index cb520ee..068f37b 100644 --- a/src/app/api/machines/heartbeat/route.ts +++ b/src/app/api/machines/heartbeat/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" const heartbeatSchema = z.object({ machineToken: z.string().min(1), @@ -59,6 +60,15 @@ export async function POST(request: Request) { const response = await client.mutation(api.devices.heartbeat, payload) return jsonWithCors(response, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.heartbeat] Falha ao registrar heartbeat", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, origin, CORS_METHODS) diff --git a/src/app/api/machines/inventory/route.ts b/src/app/api/machines/inventory/route.ts index 2ae6fb1..11ac5f9 100644 --- a/src/app/api/machines/inventory/route.ts +++ b/src/app/api/machines/inventory/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" const tokenModeSchema = z.object({ machineToken: z.string().min(1), @@ -77,6 +78,15 @@ export async function POST(request: Request) { }) return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.inventory:token] Falha ao atualizar inventário", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, origin, CORS_METHODS) @@ -94,8 +104,8 @@ export async function POST(request: Request) { macAddresses: provParsed.data.macAddresses, serialNumbers: provParsed.data.serialNumbers, inventory: provParsed.data.inventory, - metrics: provParsed.data.metrics, - registeredBy: provParsed.data.registeredBy ?? "agent:inventory", + metrics: provParsed.data.metrics, + registeredBy: provParsed.data.registeredBy ?? "agent:inventory", }) return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, origin, CORS_METHODS) } catch (error) { @@ -107,3 +117,4 @@ export async function POST(request: Request) { return jsonWithCors({ error: "Formato de payload não suportado" }, 400, origin, CORS_METHODS) } + diff --git a/src/app/api/machines/remote-access/route.ts b/src/app/api/machines/remote-access/route.ts index c5718c1..a0027af 100644 --- a/src/app/api/machines/remote-access/route.ts +++ b/src/app/api/machines/remote-access/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" export const runtime = "nodejs" @@ -54,6 +55,15 @@ export async function POST(request: Request) { const response = await client.mutation(api.devices.upsertRemoteAccessViaToken, payload) return jsonWithCors({ ok: true, remoteAccess: response?.remoteAccess ?? null }, 200, origin, METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + METHODS + ) + } console.error("[machines.remote-access:token] Falha ao registrar acesso remoto", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao registrar acesso remoto", details }, 500, origin, METHODS) diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index a7c19c7..d97bc18 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { z } from "zod" import { createMachineSession, MachineInactiveError } from "@/server/machines-session" import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors" +import { resolveMachineTokenError } from "@/server/machines/token-errors" import { MACHINE_CTX_COOKIE, serializeMachineCookie, @@ -127,13 +128,23 @@ export async function POST(request: Request) { } catch (error) { if (error instanceof MachineInactiveError) { return jsonWithCors( - { error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, + { error: "Dispositivo desativado. Entre em contato com o suporte da Rever para reativar o acesso." }, 423, origin, CORS_METHODS ) } + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.sessions] Falha ao criar sessão", error) return jsonWithCors({ error: "Falha ao autenticar dispositivo" }, 500, origin, CORS_METHODS) } } + diff --git a/src/app/api/machines/usb-policy/route.ts b/src/app/api/machines/usb-policy/route.ts index 2f9b7e6..9d8be18 100644 --- a/src/app/api/machines/usb-policy/route.ts +++ b/src/app/api/machines/usb-policy/route.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { api } from "@/convex/_generated/api" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { resolveMachineTokenError } from "@/server/machines/token-errors" const getPolicySchema = z.object({ machineToken: z.string().min(1), @@ -54,6 +55,15 @@ export async function GET(request: Request) { appliedAt: pendingPolicy.appliedAt, }, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.usb-policy] Falha ao buscar politica USB", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao buscar politica USB", details }, 500, origin, CORS_METHODS) @@ -90,6 +100,15 @@ export async function POST(request: Request) { const response = await client.mutation(api.usbPolicy.reportUsbPolicyStatus, payload) return jsonWithCors(response, 200, origin, CORS_METHODS) } catch (error) { + const tokenError = resolveMachineTokenError(error) + if (tokenError) { + return jsonWithCors( + { error: tokenError.message, code: tokenError.code }, + tokenError.status, + origin, + CORS_METHODS + ) + } console.error("[machines.usb-policy] Falha ao reportar status de politica USB", error) const details = error instanceof Error ? error.message : String(error) return jsonWithCors({ error: "Falha ao reportar status", details }, 500, origin, CORS_METHODS) diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 851a313..2556a18 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -27,6 +27,7 @@ const COLLABORATOR_VISIBLE_TYPES: NotificationType[] = [ "security_email_verify", "security_email_change", "security_new_login", + "security_invite", ] export async function GET(_request: NextRequest) { @@ -69,8 +70,8 @@ export async function GET(_request: NextRequest) { tenantId: user.tenantId, emailEnabled: true, digestFrequency: "immediate", - typePreferences: {}, - categoryPreferences: {}, + typePreferences: "{}", + categoryPreferences: "{}", }, }) } diff --git a/src/app/api/notifications/send/route.ts b/src/app/api/notifications/send/route.ts new file mode 100644 index 0000000..e97572c --- /dev/null +++ b/src/app/api/notifications/send/route.ts @@ -0,0 +1,168 @@ +/** + * API de Envio de Notificações + * Chamada pelo Convex para enviar e-mails respeitando preferências do usuário + */ + +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { prisma } from "@/lib/prisma" +import { sendEmail, type NotificationType, type TemplateName, NOTIFICATION_TYPES } from "@/server/email" + +// Token de autenticação interna (deve ser o mesmo usado no Convex) +const INTERNAL_TOKEN = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET + +const sendNotificationSchema = z.object({ + type: z.enum([ + "ticket_created", + "ticket_assigned", + "ticket_resolved", + "ticket_reopened", + "ticket_status_changed", + "ticket_priority_changed", + "comment_public", + "comment_response", + "sla_at_risk", + "sla_breached", + "automation", + ]), + to: z.object({ + email: z.string().email(), + name: z.string().optional(), + userId: z.string().optional(), + }), + subject: z.string(), + data: z.record(z.any()), + tenantId: z.string().optional(), + skipPreferenceCheck: z.boolean().optional(), +}) + +async function shouldSendNotification( + userId: string | undefined, + notificationType: NotificationType | "automation", + tenantId?: string +): Promise { + // Automações sempre passam (são configuradas separadamente) + if (notificationType === "automation") return true + + // Se não tem userId, não pode verificar preferências + if (!userId) return true + + try { + const prefs = await prisma.notificationPreferences.findUnique({ + where: { userId }, + }) + + // Se não tem preferências, usa os defaults + if (!prefs) return true + + // Se e-mail está desabilitado globalmente + if (!prefs.emailEnabled) return false + + // Verifica se é um tipo obrigatório + const config = NOTIFICATION_TYPES[notificationType as NotificationType] + if (config?.required) return true + + // Verifica preferências por tipo + const typePrefs = prefs.typePreferences + ? JSON.parse(prefs.typePreferences as string) + : {} + + if (notificationType in typePrefs) { + return typePrefs[notificationType] !== false + } + + // Usa o default do tipo + return config?.defaultEnabled ?? true + } catch (error) { + console.error("[notifications/send] Erro ao verificar preferências:", error) + // Em caso de erro, envia a notificação + return true + } +} + +function getTemplateForType(type: string): string { + const templateMap: Record = { + ticket_created: "ticket_created", + ticket_assigned: "ticket_assigned", + ticket_resolved: "ticket_resolved", + ticket_reopened: "ticket_status", + ticket_status_changed: "ticket_status", + ticket_priority_changed: "ticket_status", + comment_public: "ticket_comment", + comment_response: "ticket_comment", + sla_at_risk: "sla_warning", + sla_breached: "sla_breached", + automation: "automation", + } + return templateMap[type] ?? "simple_notification" +} + +export async function POST(request: NextRequest) { + try { + // Verifica autenticação + const authHeader = request.headers.get("authorization") + const token = authHeader?.replace("Bearer ", "") + + if (!INTERNAL_TOKEN || token !== INTERNAL_TOKEN) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = await request.json() + const parsed = sendNotificationSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: "Dados inválidos", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { type, to, subject, data, tenantId, skipPreferenceCheck } = parsed.data + + // Verifica preferências do usuário + if (!skipPreferenceCheck) { + const shouldSend = await shouldSendNotification( + to.userId, + type as NotificationType | "automation", + tenantId + ) + + if (!shouldSend) { + return NextResponse.json({ + success: true, + skipped: true, + reason: "user_preference_disabled", + }) + } + } + + // Envia o e-mail + const template = getTemplateForType(type) + const result = await sendEmail({ + to: { + email: to.email, + name: to.name, + userId: to.userId, + }, + subject, + template, + data, + notificationType: type === "automation" ? undefined : (type as NotificationType), + tenantId, + skipPreferenceCheck: true, // Já verificamos acima + }) + + return NextResponse.json({ + success: result.success, + skipped: result.skipped, + reason: result.reason, + }) + } catch (error) { + console.error("[notifications/send] Erro:", error) + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..7ebda15 --- /dev/null +++ b/src/app/api/profile/avatar/route.ts @@ -0,0 +1,245 @@ +/** + * API de Avatar + * POST - Faz upload de uma nova foto de perfil + * DELETE - Remove a foto de perfil (volta ao padrão) + */ + +import { NextResponse } from "next/server" + +import { getServerSession } from "@/lib/auth-server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { createConvexClient } from "@/server/convex-client" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] + +function extractSetCookies(headers: Headers) { + const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] | undefined } + let setCookieHeaders = + typeof headersWithGetSetCookie.getSetCookie === "function" + ? headersWithGetSetCookie.getSetCookie() ?? [] + : [] + + if (setCookieHeaders.length === 0) { + const single = headers.get("set-cookie") + if (single) { + setCookieHeaders = [single] + } + } + + return setCookieHeaders +} + +export async function POST(request: Request) { + try { + const session = await getServerSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "Nenhum arquivo enviado" }, { status: 400 }) + } + + // Valida tipo + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json( + { error: "Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF." }, + { status: 400 } + ) + } + + // Valida tamanho + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "Arquivo muito grande. Máximo 5MB." }, + { status: 400 } + ) + } + + const convex = createConvexClient() + + // Gera URL de upload + const uploadUrl = await convex.action(api.files.generateUploadUrl, {}) + + // Faz upload do arquivo + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }) + + if (!uploadResponse.ok) { + console.error("[profile/avatar] Erro no upload:", await uploadResponse.text()) + return NextResponse.json({ error: "Erro ao fazer upload" }, { status: 500 }) + } + + const { storageId } = (await uploadResponse.json()) as { storageId: Id<"_storage"> } + + // Obtém URL pública do arquivo + const avatarUrl = await convex.action(api.files.getUrl, { storageId }) + + if (!avatarUrl) { + return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 }) + } + + // Atualiza no Better Auth e propaga Set-Cookie para invalidar o cookieCache da sessao (avatarUrl, etc) + let authSetCookies: string[] = [] + try { + const updateResponse = await auth.api.updateUser({ + request, + headers: request.headers, + body: { avatarUrl }, + asResponse: true, + }) + + if (!updateResponse?.ok) { + throw new Error(`Falha ao atualizar usuario no Better Auth (${updateResponse?.status ?? "sem status"})`) + } + + authSetCookies = extractSetCookies(updateResponse.headers) + } catch (error) { + console.warn("[profile/avatar] Falha ao atualizar sessao via Better Auth, usando fallback Prisma:", error) + await prisma.authUser.update({ + where: { id: session.user.id }, + data: { avatarUrl }, + }) + } + + // Sincroniza com o Convex + try { + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + await convex.mutation(api.users.updateAvatar, { + tenantId, + email: session.user.email, + avatarUrl, + }) + } catch (error) { + console.warn("[profile/avatar] Falha ao sincronizar avatar no Convex:", error) + } + + const response = NextResponse.json({ + success: true, + avatarUrl, + }) + + for (const cookie of authSetCookies) { + response.headers.append("set-cookie", cookie) + } + + return response + } catch (error) { + console.error("[profile/avatar] Erro:", error) + return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const session = await getServerSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + // Atualiza no Better Auth e propaga Set-Cookie para invalidar o cookieCache da sessao (avatarUrl, etc) + let authSetCookies: string[] = [] + try { + const updateResponse = await auth.api.updateUser({ + request, + headers: request.headers, + body: { avatarUrl: null }, + asResponse: true, + }) + + if (!updateResponse?.ok) { + throw new Error(`Falha ao atualizar usuario no Better Auth (${updateResponse?.status ?? "sem status"})`) + } + + authSetCookies = extractSetCookies(updateResponse.headers) + } catch (error) { + console.warn("[profile/avatar] Falha ao atualizar sessao via Better Auth, usando fallback Prisma:", error) + await prisma.authUser.update({ + where: { id: session.user.id }, + data: { avatarUrl: null }, + }) + } + + // Sincroniza com o Convex + try { + const convex = createConvexClient() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + await convex.mutation(api.users.updateAvatar, { + tenantId, + email: session.user.email, + avatarUrl: null, + }) + } catch (error) { + console.warn("[profile/avatar] Falha ao sincronizar remoção de avatar no Convex:", error) + } + + const response = NextResponse.json({ + success: true, + message: "Foto removida com sucesso", + }) + + for (const cookie of authSetCookies) { + response.headers.append("set-cookie", cookie) + } + + return response + } catch (error) { + console.error("[profile/avatar] Erro ao remover:", error) + return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 }) + } +} + +/** + * PATCH - Força sincronização do avatar atual do Prisma para o Convex + * Útil quando a sincronização automática falhou + */ +export async function PATCH() { + try { + const session = await getServerSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + // Busca o avatar atual no Prisma + const user = await prisma.authUser.findUnique({ + where: { id: session.user.id }, + select: { avatarUrl: true }, + }) + + // Sincroniza com o Convex + const convex = createConvexClient() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const result = await convex.mutation(api.users.updateAvatar, { + tenantId, + email: session.user.email, + avatarUrl: user?.avatarUrl ?? null, + }) + + console.log("[profile/avatar] Sincronização forçada:", result) + + return NextResponse.json({ + success: true, + message: "Avatar sincronizado com sucesso", + avatarUrl: user?.avatarUrl ?? null, + convexResult: result, + }) + } catch (error) { + console.error("[profile/avatar] Erro na sincronização:", error) + return NextResponse.json({ error: "Erro ao sincronizar avatar" }, { status: 500 }) + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 9677c57..1d51729 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,67 +3,46 @@ @custom-variant dark (&:is(.dark *)); -@font-face { - font-family: "InterVariable"; - src: url("/fonts/Inter-VariableFont_opsz,wght.ttf") format("truetype"); - font-weight: 100 900; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: "InterVariable"; - src: url("/fonts/Inter-Italic-VariableFont_opsz,wght.ttf") format("truetype"); - font-weight: 100 900; - font-style: italic; - font-display: swap; -} - -/* JetBrains Mono agora depende das fontes do sistema para evitar carregar um arquivo corrompido */ - @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-destructive-foreground: var(--destructive-foreground); - --font-geist-mono: var(----font-geist-mono); - --font-geist-sans: var(----font-geist-sans); - --radius: var(----radius); -} + --font-sans: var(--font-inter); + --font-mono: var(--font-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-destructive-foreground: var(--destructive-foreground); +} :root { --radius: 0.75rem; @@ -99,8 +78,6 @@ --sidebar-border: #cbd5e1; --sidebar-ring: #00d6eb; --destructive-foreground: oklch(1 0 0); - --font-geist-sans: "InterVariable", "Inter", sans-serif; - --font-geist-mono: "JetBrains Mono", "Fira Code", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } .dark { @@ -255,12 +232,3 @@ animation: recent-ticket-enter 0.45s ease-out; } } - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 46ff46c..7b76fef 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,24 @@ import { ensurePerformanceMeasurePatched } from "@/lib/performance-measure-polyfill" import type { Metadata } from "next" +import { Inter, JetBrains_Mono } from "next/font/google" import "./globals.css" import { ConvexClientProvider } from "./ConvexClientProvider" import { AuthProvider } from "@/lib/auth-client" import { Toaster } from "@/components/ui/sonner" import { ChatWidgetProvider } from "@/components/chat/chat-widget-provider" +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}) + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-mono", +}) + export const metadata: Metadata = { title: "Raven - Sistema de chamados", description: "Plataforma Raven da Rever", @@ -31,7 +44,7 @@ export default async function RootLayout({ children: React.ReactNode }>) { return ( - + diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index f932c65..6b1bf0b 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -54,12 +54,19 @@ export function LoginPageClient() { return (
-
- -
- Sistema de chamados - Por Rever Tecnologia +
+ +
+ + Raven + + + Helpdesk +
+ + Por Rever Tecnologia +
@@ -81,8 +88,18 @@ export function LoginPageClient() { Desenvolvido por Esdras Renan
-
- +
+
+ +
+
+

Bem-vindo de volta

+

+ Gerencie seus chamados e tickets de forma simples +

+
+
+
) diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts index 3a6521f..4a322c3 100644 --- a/src/app/machines/handshake/route.ts +++ b/src/app/machines/handshake/route.ts @@ -9,7 +9,7 @@ const INACTIVE_TEMPLATE = ` - Dispositivo desativada + Dispositivo desativado - + - +
- +
- - - @@ -333,14 +429,14 @@ const templates: Record string> = { test: (data) => { return baseTemplate( ` -

+

${escapeHtml(data.title)}

-

+

${escapeHtml(data.message)}

-

- Enviado em: ${escapeHtml(data.timestamp)} +

+ Enviado em ${escapeHtml(data.timestamp)}

`, data @@ -352,11 +448,14 @@ const templates: Record string> = { const viewUrl = data.viewUrl as string return baseTemplate( ` -

- Chamado Aberto +

+ Chamado aberto

-

- Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve. +

+ Seu chamado foi registrado com sucesso. +

+

+ Nossa equipe irá analisá-lo e entrar em contato em breve.

${ticketInfoCard({ @@ -367,8 +466,8 @@ const templates: Record string> = { createdAt: data.createdAt as string, })} -
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -382,10 +481,16 @@ const templates: Record string> = { return baseTemplate( ` -

- Chamado Resolvido +
+
+ +
+
+ +

+ Chamado resolvido

-

+

Seu chamado foi marcado como resolvido. Esperamos que o atendimento tenha sido satisfatório!

@@ -399,22 +504,24 @@ const templates: Record string> = { ${ data.resolutionSummary ? ` -
-

RESUMO DA RESOLUÇÃO

+
+

Resumo da resolução

${escapeHtml(data.resolutionSummary)}

` : "" } -
-

Como foi o atendimento?

-

Sua avaliação nos ajuda a melhorar!

+ ${divider()} + +
+

Como foi o atendimento?

+

Sua avaliação nos ajuda a melhorar!

${ratingStars(rateUrl)}
- ${button("Ver Chamado", viewUrl)} + ${buttonSecondary("Ver detalhes", viewUrl)}
`, data @@ -426,17 +533,17 @@ const templates: Record string> = { const viewUrl = data.viewUrl as string const isForRequester = data.isForRequester as boolean - const title = isForRequester ? "Agente Atribuído ao Chamado" : "Novo Chamado Atribuído" + const title = isForRequester ? "Agente atribuído" : "Novo chamado atribuído" const message = isForRequester - ? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.` + ? `O agente ${escapeHtml(data.assigneeName)} foi atribuído ao seu chamado e em breve entrará em contato.` : `Um novo chamado foi atribuído a você. Por favor, verifique os detalhes abaixo.` return baseTemplate( ` -

+

${title}

-

+

${message}

@@ -449,8 +556,8 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -465,10 +572,10 @@ const templates: Record string> = { return baseTemplate( ` -

- Status Atualizado +

+ Status atualizado

-

+

O status do seu chamado foi alterado.

@@ -477,22 +584,24 @@ const templates: Record string> = { subject: data.subject as string, })} -
+
+ - -
- R + + R - Raven + + Raven
@@ -296,21 +392,21 @@ function baseTemplate(content: string, data: TemplateData): string {
+ ${content}
-

+

+

Este e-mail foi enviado pelo Sistema de Chamados Raven.

- Gerenciar notificações - | - Ajuda + ${textLink("Gerenciar notificações", preferencesUrl)} + | + ${textLink("Central de ajuda", helpUrl)}

- - - +
+ + Anterior ${badge(oldStatus.label, oldStatus.bg, oldStatus.color)} + + Atual ${badge(newStatus.label, newStatus.bg, newStatus.color)}
-
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -505,11 +614,11 @@ const templates: Record string> = { return baseTemplate( ` -

- Nova Atualização no Chamado +

+ Nova atualização

-

- ${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado. +

+ ${escapeHtml(data.authorName)} adicionou um comentário ao seu chamado.

${ticketInfoCard({ @@ -517,17 +626,18 @@ const templates: Record string> = { subject: data.subject as string, })} -
-

- ${escapeHtml(data.authorName)} • ${formatDate(data.commentedAt as string)} -

-

+

+
+ ${escapeHtml(data.authorName)} + ${formatDate(data.commentedAt as string)} +
+

${escapeHtml(data.commentBody)}

-
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Responder", viewUrl)}
`, data @@ -540,20 +650,25 @@ const templates: Record string> = { return baseTemplate( ` -

- Redefinição de Senha +
+
+ 🔒 +
+
+ +

+ Redefinição de senha

-

+

Recebemos uma solicitação para redefinir a senha da sua conta. Se você não fez essa solicitação, pode ignorar este e-mail.

- ${button("Redefinir Senha", resetUrl)} + ${buttonPrimary("Redefinir senha", resetUrl)}
-

- Este link expira em 24 horas. Se você não solicitou a redefinição de senha, - pode ignorar este e-mail com segurança. +

+ Este link expira em 24 horas. Se você não solicitou a redefinição de senha, pode ignorar este e-mail com segurança.

`, data @@ -566,18 +681,24 @@ const templates: Record string> = { return baseTemplate( ` -

- Confirme seu E-mail +
+
+ +
+
+ +

+ Confirme seu e-mail

-

+

Clique no botão abaixo para confirmar seu endereço de e-mail e ativar sua conta.

- ${button("Confirmar E-mail", verifyUrl)} + ${buttonPrimary("Confirmar e-mail", verifyUrl)}
-

+

Se você não criou uma conta, pode ignorar este e-mail com segurança.

`, @@ -591,37 +712,53 @@ const templates: Record string> = { return baseTemplate( ` -

+
+
+ 🎉 +
+
+ +

Você foi convidado!

-

- ${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven. +

+ ${escapeHtml(data.inviterName)} convidou você para acessar o Sistema de Chamados Raven.

-
- - - - - - ${ - data.companyName - ? ` - - - - - ` - : "" - } -
Função${escapeHtml(data.roleName)}
Empresa${escapeHtml(data.companyName)}
-
+ + + + + ${ + data.companyName + ? ` + + + + ` + : "" + } +
+ + + + + +
Função${escapeHtml(data.roleName)}
+
+ + + + + +
Empresa${escapeHtml(data.companyName)}
+
- ${button("Aceitar Convite", inviteUrl)} + ${buttonPrimary("Aceitar convite", inviteUrl)}
-

+

Este convite expira em 7 dias. Se você não esperava este convite, pode ignorá-lo com segurança.

`, @@ -633,31 +770,53 @@ const templates: Record string> = { new_login: (data) => { return baseTemplate( ` -

- Novo Acesso Detectado +
+
+ 🔒 +
+
+ +

+ Novo acesso detectado

-

+

Detectamos um novo acesso à sua conta. Se foi você, pode ignorar este e-mail.

-
- - - - - - - - - - - - - -
Data/Hora${formatDate(data.loginAt as string)}
Dispositivo${escapeHtml(data.userAgent)}
Endereço IP${escapeHtml(data.ipAddress)}
-
+ + + + + + + + + + +
+ + + + + +
Data/Hora${formatDate(data.loginAt as string)}
+
+ + + + + +
Dispositivo${escapeHtml(data.userAgent)}
+
+ + + + + +
Endereço IP${escapeHtml(data.ipAddress)}
+
-

+

Se você não reconhece este acesso, recomendamos alterar sua senha imediatamente.

`, @@ -671,11 +830,20 @@ const templates: Record string> = { return baseTemplate( ` -

- SLA em Risco +
+
+ +
+
+ +

+ SLA em risco

-

- O chamado abaixo está próximo de violar o SLA. Ação necessária! +

+ O chamado abaixo está próximo de violar o SLA. +

+

+ Ação necessária!

${ticketInfoCard({ @@ -687,17 +855,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- Tempo restante: ${escapeHtml(data.timeRemaining)} +

+

+ ${escapeHtml(data.timeRemaining)}

-

+

Prazo: ${formatDate(data.dueAt as string)}

-
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data @@ -710,11 +878,20 @@ const templates: Record string> = { return baseTemplate( ` -

- SLA Violado +
+
+ +
+
+ +

+ SLA violado

-

- O chamado abaixo violou o SLA estabelecido. Atenção urgente necessária! +

+ O chamado abaixo violou o SLA estabelecido. +

+

+ Atenção urgente necessária!

${ticketInfoCard({ @@ -726,17 +903,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- Tempo excedido: ${escapeHtml(data.timeExceeded)} +

+

+ ${escapeHtml(data.timeExceeded)}

-

+

Prazo era: ${formatDate(data.dueAt as string)}

-
- ${button("Ver Chamado", viewUrl)} +
+ ${buttonPrimary("Ver chamado", viewUrl)}
`, data diff --git a/src/server/machines/inventory-export.ts b/src/server/machines/inventory-export.ts index a0acc90..1cbdfd9 100644 --- a/src/server/machines/inventory-export.ts +++ b/src/server/machines/inventory-export.ts @@ -94,6 +94,7 @@ type MachineDerivedData = { collaborator: ReturnType remoteAccessCount: number fleetInfo: ReturnType + bootInfo: BootInfo customFieldByKey: Record customFieldById: Record } @@ -145,10 +146,10 @@ const COLUMN_VALUE_RESOLVERS: Record derived.collaborator?.name ?? null, collaboratorEmail: (_machine, derived) => derived.collaborator?.email ?? null, remoteAccessCount: (_machine, derived) => derived.remoteAccessCount, - fleetId: (_machine, derived) => derived.fleetInfo?.id ?? null, - fleetTeam: (_machine, derived) => derived.fleetInfo?.team ?? null, - fleetUpdatedAt: (_machine, derived) => - derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null, + lastBootTime: (_machine, derived) => + derived.bootInfo.lastBootTime ? formatDateTime(derived.bootInfo.lastBootTime) : null, + uptimeFormatted: (_machine, derived) => derived.bootInfo.uptimeFormatted, + bootCount30d: (_machine, derived) => derived.bootInfo.bootCount30d, managementMode: (machine) => describeManagementMode(machine.managementMode), usbPolicy: (machine) => describeUsbPolicy(machine.usbPolicy), usbPolicyStatus: (machine) => describeUsbPolicyStatus(machine.usbPolicyStatus), @@ -186,6 +187,7 @@ function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData const collaborator = extractCollaborator(machine, inventory) const remoteAccessCount = collectRemoteAccessEntries(machine).length const fleetInfo = extractFleetInfo(inventory) + const bootInfo = extractBootInfo(inventory) const customFieldByKey: Record = {} const customFieldById: Record = {} for (const field of machine.customFields ?? []) { @@ -205,6 +207,7 @@ function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData collaborator, remoteAccessCount, fleetInfo, + bootInfo, customFieldByKey, customFieldById, } @@ -1561,6 +1564,52 @@ function extractSystemInfo(inventory: Record | null): SystemInf } } +type BootInfo = { + lastBootTime: number | null + uptimeSeconds: number | null + uptimeFormatted: string | null + bootCount30d: number | null +} + +function extractBootInfo(inventory: Record | null): BootInfo { + const result: BootInfo = { + lastBootTime: null, + uptimeSeconds: null, + uptimeFormatted: null, + bootCount30d: null, + } + if (!inventory) return result + + const extended = pickRecord(inventory, ["extended", "Extended"]) + const windows = pickRecord(extended, ["windows", "Windows"]) + const bootInfo = pickRecord(windows, ["bootInfo", "BootInfo"]) + + if (!bootInfo) return result + + result.lastBootTime = + parseDateish(bootInfo["lastBootTimeUTC"]) ?? + parseDateish(bootInfo["LastBootTimeUTC"]) ?? + parseDateish(bootInfo["lastBootTime"]) ?? + parseDateish(bootInfo["LastBootUpTime"]) + + const uptimeRaw = pickNumber(bootInfo, ["uptimeSeconds", "UptimeSeconds", "uptime"]) + result.uptimeSeconds = uptimeRaw + + const uptimeFmt = pickString(bootInfo, ["uptimeFormatted", "UptimeFormatted"]) + if (uptimeFmt) { + result.uptimeFormatted = uptimeFmt + } else if (uptimeRaw !== null && uptimeRaw >= 0) { + const days = Math.floor(uptimeRaw / 86400) + const hours = Math.floor((uptimeRaw % 86400) / 3600) + const minutes = Math.floor((uptimeRaw % 3600) / 60) + result.uptimeFormatted = `${days}d ${hours}h ${minutes}m` + } + + result.bootCount30d = pickNumber(bootInfo, ["bootCount30d", "BootCount30d", "bootCount"]) + + return result +} + type SoftwareEntryInternal = SoftwareEntry function extractSoftwareEntries(hostname: string, inventory: Record | null): SoftwareEntryInternal[] { diff --git a/src/server/machines/token-errors.ts b/src/server/machines/token-errors.ts new file mode 100644 index 0000000..a7d966b --- /dev/null +++ b/src/server/machines/token-errors.ts @@ -0,0 +1,43 @@ +type MachineTokenErrorKind = "invalid" | "expired" | "revoked" + +type MachineTokenErrorMatch = { + match: string + kind: MachineTokenErrorKind +} + +const MACHINE_TOKEN_ERROR_MATCHES: MachineTokenErrorMatch[] = [ + { match: "Token de dispositivo inválido", kind: "invalid" }, + { match: "Token de dispositivo invalido", kind: "invalid" }, + { match: "Token de dispositivo expirado", kind: "expired" }, + { match: "Token de dispositivo revogado", kind: "revoked" }, + { match: "Token de maquina invalido ou revogado", kind: "revoked" }, + { match: "Token de máquina inválido", kind: "invalid" }, + { match: "Token de maquina invalido", kind: "invalid" }, + { match: "Token de máquina expirado", kind: "expired" }, + { match: "Token de maquina expirado", kind: "expired" }, + { match: "Token de máquina revogado", kind: "revoked" }, + { match: "Token de maquina revogado", kind: "revoked" }, +] + +export type MachineTokenErrorInfo = { + kind: MachineTokenErrorKind + message: string + status: number + code: string +} + +export function resolveMachineTokenError(error: unknown): MachineTokenErrorInfo | null { + const message = error instanceof Error ? error.message : String(error) + const match = MACHINE_TOKEN_ERROR_MATCHES.find((entry) => message.includes(entry.match)) + if (!match) { + return null + } + + const status = match.kind === "revoked" ? 403 : 401 + return { + kind: match.kind, + message: match.match, + status, + code: `machine_token_${match.kind}`, + } +} diff --git a/stack.yml b/stack.yml index f965133..2270f61 100644 --- a/stack.yml +++ b/stack.yml @@ -21,18 +21,18 @@ services: # IMPORTANTE: "NEXT_PUBLIC_*" é consumida pelo navegador (cliente). Use a URL pública do Convex. # Não use o hostname interno do Swarm aqui, pois o browser não consegue resolvê-lo. NEXT_PUBLIC_CONVEX_URL: "${NEXT_PUBLIC_CONVEX_URL}" - # URLs consumidas apenas pelo backend/SSR podem usar o hostname interno - CONVEX_INTERNAL_URL: "http://sistema_convex_backend:3210" + # URLs consumidas apenas pelo backend/SSR usam o endpoint publico para evitar falhas de DNS interno + CONVEX_INTERNAL_URL: "https://convex.esdrasrenan.com.br" # URLs públicas do app (evita fallback para localhost) NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}" BETTER_AUTH_URL: "${BETTER_AUTH_URL}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" REPORTS_CRON_SECRET: "${REPORTS_CRON_SECRET}" REPORTS_CRON_BASE_URL: "${REPORTS_CRON_BASE_URL}" - # PostgreSQL connection string (usa o servico 'postgres' existente na rede traefik_public) + # PostgreSQL connection string (usa o servico 'postgres18' existente na rede traefik_public) # connection_limit: maximo de conexoes por replica (2 replicas x 10 = 20 conexoes) # pool_timeout: tempo maximo para aguardar conexao disponivel - DATABASE_URL: "postgresql://${POSTGRES_USER:-sistema}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-sistema_chamados}?connection_limit=10&pool_timeout=10" + DATABASE_URL: "postgresql://${POSTGRES_USER:-sistema}:${POSTGRES_PASSWORD}@postgres18:5432/${POSTGRES_DB:-sistema_chamados}?connection_limit=10&pool_timeout=10" # Evita apt-get na inicialização porque a imagem já vem com toolchain pronta SKIP_APT_BOOTSTRAP: "true" # Usado para forçar novo rollout a cada deploy (setado pelo CI) @@ -87,18 +87,21 @@ services: # O novo container só entra em serviço APÓS passar no healthcheck start_period: 180s - # PostgreSQL: usando o servico 'postgres' existente na rede traefik_public + # PostgreSQL: usando o servico 'postgres18' existente na rede traefik_public # Nao e necessario definir aqui pois ja existe um servico global convex_backend: # Versao estavel - crons movidos para /api/cron/* chamados via crontab do Linux - image: ghcr.io/get-convex/convex-backend:precompiled-2025-12-04-cc6af4c + image: ghcr.io/get-convex/convex-backend:6690a911bced1e5e516eafc0409a7239fb6541bb stop_grace_period: 10s stop_signal: SIGINT + command: + - --convex-http-proxy + - http://convex_proxy:8888 volumes: - convex_data:/convex/data environment: - - RUST_LOG=info + - RUST_LOG=info,common::errors=error - CONVEX_CLOUD_ORIGIN=https://convex.esdrasrenan.com.br - CONVEX_SITE_ORIGIN=https://convex.esdrasrenan.com.br # Provisionamento de máquinas (usado pelas functions do Convex) @@ -136,9 +139,17 @@ services: - traefik.http.routers.sistema_convex.entrypoints=websecure - traefik.http.routers.sistema_convex.tls=true - traefik.http.routers.sistema_convex.tls.certresolver=le + - traefik.http.routers.sistema_convex.priority=1 + - traefik.http.routers.sistema_convex_api_json.rule=Host(`convex.esdrasrenan.com.br`) && PathPrefix(`/api/`) && Method(`POST`) && HeadersRegexp(`Content-Type`, `(?i)^application/json(\\s*;.*)?$$`) + - traefik.http.routers.sistema_convex_api_json.entrypoints=websecure + - traefik.http.routers.sistema_convex_api_json.tls=true + - traefik.http.routers.sistema_convex_api_json.tls.certresolver=le + - traefik.http.routers.sistema_convex_api_json.priority=100 + - traefik.http.routers.sistema_convex_api_json.service=sistema_convex - traefik.http.services.sistema_convex.loadbalancer.server.port=3210 networks: - traefik_public + - convex_internal healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3210/version >/dev/null || exit 1"] interval: 15s @@ -146,6 +157,47 @@ services: retries: 3 start_period: 60s + convex_proxy: + image: monokal/tinyproxy:latest + command: + - ANY + deploy: + mode: replicated + replicas: 1 + resources: + limits: + memory: "256M" + placement: + constraints: + - node.role == manager + networks: + - convex_internal + + convex_block: + image: hashicorp/http-echo:1.0.0 + command: + - -listen=:8080 + - -status-code=415 + - -text=unsupported content type + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == manager + labels: + - traefik.enable=true + - traefik.docker.network=traefik_public + - traefik.http.routers.sistema_convex_api_block.rule=Host(`convex.esdrasrenan.com.br`) && PathPrefix(`/api/`) && Method(`POST`) + - traefik.http.routers.sistema_convex_api_block.entrypoints=websecure + - traefik.http.routers.sistema_convex_api_block.tls=true + - traefik.http.routers.sistema_convex_api_block.tls.certresolver=le + - traefik.http.routers.sistema_convex_api_block.priority=50 + - traefik.http.routers.sistema_convex_api_block.service=sistema_convex_block + - traefik.http.services.sistema_convex_block.loadbalancer.server.port=8080 + networks: + - traefik_public + convex_dashboard: image: ghcr.io/get-convex/convex-dashboard:latest environment: @@ -173,3 +225,6 @@ volumes: networks: traefik_public: external: true + convex_internal: + driver: overlay + internal: true diff --git a/tests/automations-engine.test.ts b/tests/automations-engine.test.ts index c24ea9e..483b060 100644 --- a/tests/automations-engine.test.ts +++ b/tests/automations-engine.test.ts @@ -287,7 +287,18 @@ describe("automations.runTicketAutomationsForEvent", () => { expect.objectContaining({ to: ["cliente@empresa.com"], subject: "Atualização do chamado #123", - html: expect.any(String), + emailProps: expect.objectContaining({ + title: "Atualização do chamado #123", + message: "Olá Renan, recebemos seu chamado: Teste de automação", + ticket: expect.objectContaining({ + reference: 123, + subject: "Teste de automação", + status: "PENDING", + priority: "MEDIUM", + requesterName: "Renan", + }), + ctaLabel: "Abrir chamado", + }), }) ) }) diff --git a/tests/change-assignee-comment.test.ts b/tests/change-assignee-comment.test.ts deleted file mode 100644 index d9e18f9..0000000 --- a/tests/change-assignee-comment.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "bun:test" - -import { buildAssigneeChangeComment } from "../convex/tickets" - -describe("buildAssigneeChangeComment", () => { - it("inclui nomes antigos e novos e quebra o motivo em parágrafos", () => { - const html = buildAssigneeChangeComment("Transferir para o time B\nCliente solicitou gestor.", { - previousName: "Ana", - nextName: "Bruno", - }) - - expect(html).toContain("Ana") - expect(html).toContain("Bruno") - expect(html).toContain("

Transferir para o time B

") - expect(html).toContain("

Cliente solicitou gestor.

") - }) - - it("escapa caracteres perigosos", () => { - const html = buildAssigneeChangeComment("", { - previousName: "", - nextName: "Bruno & Co", - }) - - expect(html).toContain("<Ana>") - expect(html).toContain("Bruno & Co") - expect(html).not.toContain("