diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1a12f45..7de2054 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,63 +29,7 @@ "Bash(git commit:*)", "Bash(git push:*)", "Bash(cargo check:*)", - "Bash(bun run:*)", - "Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")", - "Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")", - "Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")", - "Bash(cmd /c \"echo %TEMP%\")", - "Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")", - "Bash(where:*)", - "Bash(ssh-keygen:*)", - "Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)", - "Bash(npx convex deploy:*)", - "Bash(dir \"%LOCALAPPDATA%\\Raven\")", - "Bash(dir \"%APPDATA%\\Raven\")", - "Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")", - "Bash(dir \"%APPDATA%\\com.raven.app\")", - "Bash(tasklist:*)", - "Bash(dir /s /b %LOCALAPPDATA%*raven*)", - "Bash(cmd /c \"tasklist | findstr /i raven\")", - "Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")", - "Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")", - "Bash(node:*)", - "Bash(bun scripts/test-all-emails.tsx:*)", - "Bash(bun scripts/send-test-react-email.tsx:*)", - "Bash(dir:*)", - "Bash(git reset:*)", - "Bash(npx convex:*)", - "Bash(bun tsc:*)", - "Bash(scp:*)", - "Bash(docker run:*)", - "Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")", - "Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")", - "Bash(cmd /c \"docker --version && docker ps -a\")", - "Bash(powershell -Command \"docker --version\")", - "Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")", - "Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)", - "Bash(bunx prisma migrate:*)", - "Bash(bunx prisma db push:*)", - "Bash(bun run auth:seed:*)", - "Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)", - "Bash(bun tsx:*)", - "Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)", - "Bash(docker stop:*)", - "Bash(docker rm:*)", - "Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", - "Bash(timeout 90 git push:*)", - "Bash(docker ps:*)", - "Bash(docker start:*)", - "Bash(docker inspect:*)", - "Bash(docker exec:*)", - "Bash(timeout 90 git push)", - "Bash(bun test:*)", - "Bash(git restore:*)", - "Bash(cd:*)", - "Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\\src\\components\\ui\" /b)", - "Bash(timeout 120 bun:*)", - "Bash(bun run tauri:build:*)", - "Bash(git remote:*)", - "Bash(powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:/Projetos IA/sistema-de-chamados/scripts/test-windows-collection.ps1\")" + "Bash(bun run:*)" ] } } diff --git a/.env.example b/.env.example index 739bb8b..1f0e247 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,8 @@ REPORTS_CRON_SECRET=reports-cron-secret # Diretório para arquivamento local de tickets (JSONL/backup) ARCHIVE_DIR=./archives -# PostgreSQL database (versao 18) -# Para desenvolvimento local, use Docker: -# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 +# PostgreSQL database +# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados # SMTP Configuration (production values in docs/SMTP.md) diff --git a/.forgejo/workflows/ci-cd-web-desktop.yml b/.forgejo/workflows/ci-cd-web-desktop.yml deleted file mode 100644 index db80c21..0000000 --- a/.forgejo/workflows/ci-cd-web-desktop.yml +++ /dev/null @@ -1,492 +0,0 @@ -name: CI/CD Web + Desktop - -on: - push: - branches: [ main ] - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - force_web_deploy: - description: 'Forcar deploy do Web (ignorar filtro)?' - type: boolean - required: false - default: false - force_convex_deploy: - description: 'Forcar deploy do Convex (ignorar filtro)?' - type: boolean - required: false - default: false - -env: - APP_DIR: /srv/apps/sistema - VPS_UPDATES_DIR: /var/www/updates - -jobs: - changes: - name: Detect changes - runs-on: [ self-hosted, linux, vps ] - timeout-minutes: 5 - outputs: - convex: ${{ steps.filter.outputs.convex }} - web: ${{ steps.filter.outputs.web }} - steps: - - name: Checkout - uses: https://github.com/actions/checkout@v4 - - name: Paths filter - id: filter - uses: https://github.com/dorny/paths-filter@v3 - with: - filters: | - convex: - - 'convex/**' - web: - - 'src/**' - - 'public/**' - - 'prisma/**' - - 'next.config.ts' - - 'package.json' - - 'bun.lock' - - 'tsconfig.json' - - 'middleware.ts' - - 'stack.yml' - - deploy: - name: Deploy (VPS Linux) - needs: changes - timeout-minutes: 30 - if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} - runs-on: [ self-hosted, linux, vps ] - steps: - - name: Checkout - uses: https://github.com/actions/checkout@v4 - - - name: Determine APP_DIR (fallback safe path) - id: appdir - run: | - TS=$(date +%s) - FALLBACK_DIR="$HOME/apps/web.build.$TS" - mkdir -p "$FALLBACK_DIR" - echo "Using APP_DIR (fallback)=$FALLBACK_DIR" - echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV" - - - name: Setup Bun - uses: https://github.com/oven-sh/setup-bun@v2 - with: - bun-version: 1.3.4 - - - name: Sync workspace to APP_DIR (preserving local env) - run: | - mkdir -p "$EFFECTIVE_APP_DIR" - RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete" - EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'" - if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then - EXCLUDE_ENV="" - fi - rsync $RSYNC_FLAGS \ - --filter='protect .next.old*' \ - --exclude '.next.old*' \ - --filter='protect node_modules' \ - --filter='protect node_modules/**' \ - --filter='protect .pnpm-store' \ - --filter='protect .pnpm-store/**' \ - --filter='protect .env' \ - --filter='protect .env*' \ - --filter='protect apps/desktop/.env*' \ - --filter='protect convex/.env*' \ - --exclude '.git' \ - --exclude '.next' \ - --exclude 'node_modules' \ - --exclude 'node_modules/**' \ - --exclude '.pnpm-store' \ - --exclude '.pnpm-store/**' \ - $EXCLUDE_ENV \ - ./ "$EFFECTIVE_APP_DIR"/ - - - name: Acquire Convex admin key - id: key - run: | - echo "Waiting for Convex container..." - CID="" - for attempt in $(seq 1 12); do - CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}') - if [ -n "$CID" ]; then - echo "Convex container ready (CID=$CID)" - break - fi - echo "Attempt $attempt/12: container not ready yet; waiting 5s..." - sleep 5 - done - CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest" - if [ -n "$CID" ]; then - KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1) - else - echo "No running convex container detected; attempting offline admin key extraction..." - VOLUME="sistema_convex_data" - if docker volume inspect "$VOLUME" >/dev/null 2>&1; then - KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1) - else - echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin" - fi - fi - echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT - echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)" - if [ -z "$KEY" ]; then - echo "ERRO: Nao foi possivel obter a chave admin do Convex" - docker service ps sistema_convex_backend || true - exit 1 - fi - - - name: Copy production .env if present - run: | - DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}" - if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then - echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR" - cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env" - fi - - - name: Ensure Next.js cache directory exists and is writable - run: | - cd "$EFFECTIVE_APP_DIR" - mkdir -p .next/cache - chmod -R u+rwX .next || true - - - name: Cache Next.js build cache (.next/cache) - uses: https://github.com/actions/cache@v4 - with: - path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('next.config.ts') }} - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}- - ${{ runner.os }}-nextjs- - - - name: Lint check (fail fast before build) - run: | - cd "$EFFECTIVE_APP_DIR" - docker run --rm \ - -v "$EFFECTIVE_APP_DIR":/app \ - -w /app \ - sistema_web:node22-bun \ - bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint" - - - name: Install and build (Next.js) - env: - PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1" - run: | - cd "$EFFECTIVE_APP_DIR" - docker run --rm \ - -e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \ - -e NODE_OPTIONS="--max-old-space-size=4096" \ - -v "$EFFECTIVE_APP_DIR":/app \ - -w /app \ - sistema_web:node22-bun \ - bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun" - - - name: Fix Docker-created file permissions - run: | - # Docker cria arquivos como root - corrigir para o usuario runner (UID 1000) - docker run --rm -v "$EFFECTIVE_APP_DIR":/target alpine:3 \ - chown -R 1000:1000 /target - echo "Permissoes do build corrigidas" - - - name: Atualizar symlink do APP_DIR estavel (deploy atomico) - run: | - set -euo pipefail - ROOT="$HOME/apps" - STABLE_LINK="$ROOT/sistema.current" - - mkdir -p "$ROOT" - - # Sanidade: se esses arquivos nao existirem, o container vai falhar no boot. - test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; } - test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; } - test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; } - test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; } - - PREV="" - if [ -L "$STABLE_LINK" ]; then - PREV="$(readlink -f "$STABLE_LINK" || true)" - fi - echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV" - - ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK" - - # Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta). - if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then - ln -sfn "$STABLE_LINK" "$ROOT/sistema" - fi - - echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")" - - - name: Swarm deploy (stack.yml) - run: | - APP_DIR_STABLE="$HOME/apps/sistema.current" - if [ ! -d "$APP_DIR_STABLE" ]; then - echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1 - fi - cd "$APP_DIR_STABLE" - set -o allexport - if [ -f .env ]; then - echo "Loading .env from $APP_DIR_STABLE" - . ./.env - else - echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!" - fi - set +o allexport - echo "Using APP_DIR (stable)=$APP_DIR_STABLE" - echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-}" - 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 deleted file mode 100644 index daed18b..0000000 --- a/.forgejo/workflows/quality-checks.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Quality Checks - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - lint-test-build: - name: Lint, Test and Build - runs-on: [ self-hosted, linux, vps ] - env: - BETTER_AUTH_SECRET: test-secret - NEXT_PUBLIC_APP_URL: http://localhost:3000 - BETTER_AUTH_URL: http://localhost:3000 - NEXT_PUBLIC_CONVEX_URL: http://localhost:3210 - DATABASE_URL: file:./prisma/db.dev.sqlite - steps: - - name: Checkout - uses: https://github.com/actions/checkout@v4 - - - name: Setup Bun - uses: https://github.com/oven-sh/setup-bun@v2 - with: - bun-version: 1.3.4 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Cache Next.js build cache - uses: https://github.com/actions/cache@v4 - with: - path: | - ${{ github.workspace }}/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }} - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}- - - - name: Generate Prisma client - env: - PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1" - run: bun run prisma:generate - - - name: Lint - run: bun run lint - - - name: Test - run: bun test - - - name: Build - run: bun run build:bun diff --git a/.github/workflows.disabled/ci-cd-web-desktop.yml b/.github/workflows/ci-cd-web-desktop.yml similarity index 100% rename from .github/workflows.disabled/ci-cd-web-desktop.yml rename to .github/workflows/ci-cd-web-desktop.yml diff --git a/.github/workflows.disabled/desktop-release.yml b/.github/workflows/desktop-release.yml similarity index 100% rename from .github/workflows.disabled/desktop-release.yml rename to .github/workflows/desktop-release.yml diff --git a/.github/workflows.disabled/quality-checks.yml b/.github/workflows/quality-checks.yml similarity index 100% rename from .github/workflows.disabled/quality-checks.yml rename to .github/workflows/quality-checks.yml diff --git a/.gitignore b/.gitignore index 30d6e0c..80e6de6 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,3 @@ rustdesk/ # Prisma generated files src/generated/ -apps/desktop/service/target/ diff --git a/Dockerfile.prod b/Dockerfile.prod index bb79ec4..e08ccdc 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,4 +1,4 @@ -# Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled +# Runtime image with Node 22 + Bun 1.3.2 and build toolchain preinstalled FROM node:22-bullseye-slim ENV BUN_INSTALL=/root/.bun @@ -17,9 +17,9 @@ RUN apt-get update -y \ git \ && rm -rf /var/lib/apt/lists/* -# Install Bun 1.3.4 +# Install Bun 1.3.2 RUN curl -fsSL https://bun.sh/install \ - | bash -s -- bun-v1.3.4 \ + | bash -s -- bun-v1.3.2 \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bun \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bunx diff --git a/agents.md b/agents.md index fcac12d..0ed7d0c 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 (18/12/2025) -- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback). +## Stack atual (06/11/2025) +- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback). - Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`. -- **React / React DOM**: `19.2.1`. +- **React / React DOM**: `19.2.0`. - **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`). - **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS. - **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro. @@ -38,7 +38,7 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=dev-only-long-random-string NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 - DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados + DATABASE_URL=file:./prisma/db.dev.sqlite ``` 3. `bun run auth:seed` 4. (Opcional) `bun run queues:ensure` @@ -47,8 +47,8 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas 7. Acesse `http://localhost:3000` e valide login com os usuários padrão. ### Banco de dados -- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`. -- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`). +- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`). +- Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`). - Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.). ### Verificações antes de PR/deploy @@ -104,12 +104,12 @@ bun run build:bun ln -sfn /home/renan/apps/sistema.build. /home/renan/apps/sistema.current docker service update --force sistema_web ``` -- Resolver `P3009` (migration falhou) no PostgreSQL ativo: +- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`: ```bash docker service scale sistema_web=0 - docker run --rm -it --network traefik_public \ - --env-file /home/renan/apps/sistema.current/.env \ + docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \ -v /home/renan/apps/sistema.current:/app \ + -v sistema_sistema_db:/app/data -w /app \ oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back && bun x prisma migrate deploy" docker service scale sistema_web=1 ``` @@ -164,51 +164,8 @@ bun run build:bun - **Docs complementares**: - `docs/DEV.md` — guia diário atualizado. - `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog. - - `docs/OPERATIONS.md` — runbook do Swarm. + - `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm. - `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente. -## Regras de Codigo - -### Tooltips Nativos do Navegador - -**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc). - -O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao. - -```tsx -// ERRADO - causa tooltip nativo do navegador - - -// 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: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._ +_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c403b7..00e9106 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,9 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "node ./scripts/tauri-with-stub.mjs", - "gen:icon": "node ./scripts/build-icon.mjs", - "build:service": "cd service && cargo build --release", - "build:all": "bun run build:service && bun run tauri build" + "gen:icon": "node ./scripts/build-icon.mjs" }, "dependencies": { "@radix-ui/react-scroll-area": "^1.2.3", @@ -21,7 +19,6 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2", - "convex": "^1.31.0", "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/apps/desktop/public/logo-raven.png b/apps/desktop/public/logo-raven.png deleted file mode 100644 index 62b264e..0000000 Binary files a/apps/desktop/public/logo-raven.png and /dev/null differ diff --git a/apps/desktop/service/Cargo.lock b/apps/desktop/service/Cargo.lock deleted file mode 100644 index da860fc..0000000 --- a/apps/desktop/service/Cargo.lock +++ /dev/null @@ -1,1931 +0,0 @@ -# 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 deleted file mode 100644 index a1334d5..0000000 --- a/apps/desktop/service/Cargo.toml +++ /dev/null @@ -1,70 +0,0 @@ -[package] -name = "raven-service" -version = "0.1.0" -description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop" -authors = ["Esdras Renan"] -edition = "2021" - -[[bin]] -name = "raven-service" -path = "src/main.rs" - -[dependencies] -# Windows Service -windows-service = "0.7" - -# Async runtime -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] } - -# IPC via Named Pipes -interprocess = { version = "2", features = ["tokio"] } - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Windows Registry -winreg = "0.55" - -# Error handling -thiserror = "1.0" - -# HTTP client (para RustDesk) -reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } - -# Date/time -chrono = { version = "0.4", features = ["serde"] } - -# Crypto (para RustDesk ID) -sha2 = "0.10" - -# UUID para request IDs -uuid = { version = "1", features = ["v4"] } - -# Parking lot para locks -parking_lot = "0.12" - -# Once cell para singletons -once_cell = "1.19" - -[target.'cfg(windows)'.dependencies] -windows = { version = "0.58", features = [ - "Win32_Foundation", - "Win32_Security", - "Win32_System_Services", - "Win32_System_Threading", - "Win32_System_Pipes", - "Win32_System_IO", - "Win32_System_SystemServices", - "Win32_Storage_FileSystem", -] } - -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -strip = true diff --git a/apps/desktop/service/src/ipc.rs b/apps/desktop/service/src/ipc.rs deleted file mode 100644 index 26091b6..0000000 --- a/apps/desktop/service/src/ipc.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Modulo IPC - Servidor de Named Pipes -//! -//! Implementa comunicacao entre o Raven UI e o Raven Service -//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado. - -use crate::{rustdesk, usb_policy}; -use serde::{Deserialize, Serialize}; -use std::io::{BufRead, BufReader, Write}; -use thiserror::Error; -use tracing::{debug, info, warn}; - -#[derive(Debug, Error)] -pub enum IpcError { - #[error("Erro de IO: {0}")] - Io(#[from] std::io::Error), - - #[error("Erro de serializacao: {0}")] - Json(#[from] serde_json::Error), -} - -/// Requisicao JSON-RPC simplificada -#[derive(Debug, Deserialize)] -pub struct Request { - pub id: String, - pub method: String, - #[serde(default)] - pub params: serde_json::Value, -} - -/// Resposta JSON-RPC simplificada -#[derive(Debug, Serialize)] -pub struct Response { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(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 deleted file mode 100644 index 208e22c..0000000 --- a/apps/desktop/service/src/main.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Raven Service - Servico Windows para operacoes privilegiadas -//! -//! Este servico roda como LocalSystem e executa operacoes que requerem -//! privilegios de administrador, como: -//! - Aplicar politicas de USB -//! - Provisionar e configurar RustDesk -//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE -//! -//! O app Raven UI comunica com este servico via Named Pipes. - -mod ipc; -mod rustdesk; -mod usb_policy; - -use std::ffi::OsString; -use std::time::Duration; -use tracing::{error, info}; -use windows_service::{ - define_windows_service, - service::{ - ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, - ServiceType, - }, - service_control_handler::{self, ServiceControlHandlerResult}, - service_dispatcher, -}; - -const SERVICE_NAME: &str = "RavenService"; -const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service"; -const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)"; -const PIPE_NAME: &str = r"\\.\pipe\RavenService"; - -define_windows_service!(ffi_service_main, service_main); - -fn main() -> Result<(), Box> { - // 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 deleted file mode 100644 index 0df60aa..0000000 --- a/apps/desktop/service/src/rustdesk.rs +++ /dev/null @@ -1,846 +0,0 @@ -//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk -//! -//! Gerencia a instalacao, configuracao e provisionamento do RustDesk. -//! Como o servico roda como LocalSystem, nao precisa de elevacao. - -use chrono::Utc; -use once_cell::sync::Lazy; -use parking_lot::Mutex; -use reqwest::blocking::Client; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::env; -use std::ffi::OsStr; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::os::windows::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::thread; -use std::time::Duration; -use thiserror::Error; -use tracing::{error, info, warn}; - -const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest"; -const USER_AGENT: &str = "RavenService/1.0"; -const SERVER_HOST: &str = "rust.rever.com.br"; -const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI="; -const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI> = 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 deleted file mode 100644 index ed8144d..0000000 --- a/apps/desktop/service/src/usb_policy.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Modulo USB Policy - Controle de dispositivos USB -//! -//! Implementa o controle de armazenamento USB no Windows. -//! Como o servico roda como LocalSystem, nao precisa de elevacao. - -use serde::{Deserialize, Serialize}; -use std::io; -use thiserror::Error; -use tracing::{error, info, warn}; -use winreg::enums::*; -use winreg::RegKey; - -// GUID para Removable Storage Devices (Disk) -const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}"; - -// Chaves de registro -const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices"; -const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR"; -const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum UsbPolicy { - Allow, - BlockAll, - Readonly, -} - -impl UsbPolicy { - pub fn from_str(s: &str) -> Option { - 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 f5d4b76..86e04da 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -63,7 +63,6 @@ dependencies = [ "base64 0.22.1", "chrono", "convex", - "dirs 5.0.1", "futures-util", "get_if_addrs", "hostname", @@ -81,12 +80,10 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", - "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "thiserror 1.0.69", "tokio", - "uuid", "winreg", ] @@ -939,34 +936,13 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -977,7 +953,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -3651,17 +3627,6 @@ dependencies = [ "bitflags 2.9.4", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4549,7 +4514,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.3", @@ -4599,7 +4564,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -4783,21 +4748,6 @@ dependencies = [ "tauri-plugin", ] -[[package]] -name = "tauri-plugin-single-instance" -version = "2.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" -dependencies = [ - "serde", - "serde_json", - "tauri", - "thiserror 2.0.17", - "tracing", - "windows-sys 0.60.2", - "zbus", -] - [[package]] name = "tauri-plugin-store" version = "2.4.0" @@ -4821,7 +4771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs 6.0.0", + "dirs", "flate2", "futures-util", "http", @@ -5357,7 +5307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs", "libappindicator", "muda", "objc2 0.6.3", @@ -6138,15 +6088,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6198,21 +6139,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6270,12 +6196,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6294,12 +6214,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6318,12 +6232,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6354,12 +6262,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6378,12 +6280,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6402,12 +6298,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6426,12 +6316,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6494,7 +6378,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs", "dpi", "dunce", "gdkx11", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8e26952..efa7052 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -26,7 +26,6 @@ tauri-plugin-updater = "2.9.0" tauri-plugin-process = "2.3.0" tauri-plugin-notification = "2" tauri-plugin-deep-link = "2" -tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } @@ -42,8 +41,6 @@ hostname = "0.4" base64 = "0.22" sha2 = "0.10" convex = "0.10.2" -uuid = { version = "1", features = ["v4"] } -dirs = "5" # SSE usa reqwest com stream, nao precisa de websocket [target.'cfg(windows)'.dependencies] diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index a0cf79b..e633b09 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-*", "chat-hub"], + "windows": ["main", "chat-*"], "permissions": [ "core:default", "core:event:default", @@ -14,7 +14,6 @@ "core:window:allow-hide", "core:window:allow-show", "core:window:allow-set-focus", - "core:window:allow-start-dragging", "dialog:allow-open", "opener:default", "store:default", diff --git a/apps/desktop/src-tauri/installer-hooks.nsh b/apps/desktop/src-tauri/installer-hooks.nsh index 72de836..f6e32c6 100644 --- a/apps/desktop/src-tauri/installer-hooks.nsh +++ b/apps/desktop/src-tauri/installer-hooks.nsh @@ -1,121 +1,20 @@ ; Hooks customizadas do instalador NSIS (Tauri) ; -; Objetivo: -; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo -; - Instalar o Raven Service para operacoes privilegiadas sem UAC +; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo. ; ; Nota: o bundler do Tauri injeta estes macros no script principal do instalador. BrandingText " " !macro NSIS_HOOK_PREINSTALL - ; Para e remove qualquer instancia anterior do servico antes de atualizar - DetailPrint "Parando servicos anteriores..." - - ; Para o servico - nsExec::ExecToLog 'sc stop RavenService' - - ; Aguarda o servico parar completamente (ate 10 segundos) - nsExec::ExecToLog 'powershell -Command "$$i=0; while((Get-Service RavenService -ErrorAction SilentlyContinue).Status -eq \"Running\" -and $$i -lt 10){Start-Sleep 1;$$i++}"' - - ; Remove o servico antigo (IMPORTANTE para reinstalacoes) - DetailPrint "Removendo servico antigo..." - IfFileExists "$INSTDIR\raven-service.exe" 0 +2 - nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall' - - ; Fallback: remove via sc delete se o executavel nao existir - nsExec::ExecToLog 'sc delete RavenService' - - ; Forca encerramento de processos remanescentes - nsExec::ExecToLog 'taskkill /F /IM raven-service.exe' - nsExec::ExecToLog 'taskkill /F /IM appsdesktop.exe' - - ; Aguarda liberacao dos arquivos e remocao completa do servico - Sleep 3000 !macroend !macro NSIS_HOOK_POSTINSTALL - ; ========================================================================= - ; Instala e inicia o Raven Service - ; ========================================================================= - - DetailPrint "Instalando Raven Service..." - - ; Garante que nao ha servico residual - nsExec::ExecToLog 'sc delete RavenService' - Sleep 1000 - - ; O servico ja esta em $INSTDIR (copiado como resource pelo Tauri) - ; Registra o servico Windows - nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install' - Pop $0 - - ${If} $0 != 0 - DetailPrint "Aviso: Falha ao registrar servico (codigo: $0)" - ; Tenta remover completamente e reinstalar - nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall' - nsExec::ExecToLog 'sc delete RavenService' - Sleep 1000 - nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install' - Pop $0 - ${EndIf} - - ; Aguarda registro do servico - Sleep 500 - - ; Inicia o servico - DetailPrint "Iniciando Raven Service..." - nsExec::ExecToLog 'sc start RavenService' - Pop $0 - - ${If} $0 == 0 - DetailPrint "Raven Service iniciado com sucesso!" - ${Else} - ; Tenta novamente apos breve espera - Sleep 1000 - nsExec::ExecToLog 'sc start RavenService' - Pop $0 - ${If} $0 == 0 - DetailPrint "Raven Service iniciado com sucesso (segunda tentativa)!" - ${Else} - DetailPrint "Aviso: Servico sera iniciado na proxima reinicializacao (codigo: $0)" - ${EndIf} - ${EndIf} - - ; ========================================================================= - ; Verifica se RustDesk esta instalado - ; Se nao estiver, o Raven Service instalara automaticamente no primeiro uso - ; ========================================================================= - - IfFileExists "$PROGRAMFILES\RustDesk\rustdesk.exe" rustdesk_found rustdesk_not_found - - rustdesk_not_found: - DetailPrint "RustDesk sera instalado automaticamente pelo Raven Service." - Goto rustdesk_done - - rustdesk_found: - DetailPrint "RustDesk ja esta instalado." - - rustdesk_done: !macroend !macro NSIS_HOOK_PREUNINSTALL - ; ========================================================================= - ; Para e remove o Raven Service - ; ========================================================================= - - DetailPrint "Parando Raven Service..." - nsExec::ExecToLog 'sc stop RavenService' - Sleep 1000 - - DetailPrint "Removendo Raven Service..." - nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall' - - ; Aguarda um pouco para garantir que o servico foi removido - Sleep 500 !macroend !macro NSIS_HOOK_POSTUNINSTALL - ; Nada adicional necessario !macroend diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index 1d42d23..b663005 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().is_multiple_of(2) { + if bytes.len() % 2 != 0 { return None; } let utf16: Vec = bytes @@ -971,169 +971,6 @@ fn collect_windows_extended() -> serde_json::Value { "#).unwrap_or_else(|| json!([])); let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); - // Bateria (notebooks/laptops) - let battery = ps(r#" - $batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime) - if ($batteries.Count -eq 0) { - [PSCustomObject]@{ Present = $false; Batteries = @() } - } else { - # Mapeia status numérico para texto - $statusMap = @{ - 1 = 'Discharging' - 2 = 'AC Power' - 3 = 'Fully Charged' - 4 = 'Low' - 5 = 'Critical' - 6 = 'Charging' - 7 = 'Charging High' - 8 = 'Charging Low' - 9 = 'Charging Critical' - 10 = 'Undefined' - 11 = 'Partially Charged' - } - foreach ($b in $batteries) { - if ($b.BatteryStatus) { - $b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force - } - } - [PSCustomObject]@{ Present = $true; Batteries = $batteries } - } - "#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] })); - - // Sensores térmicos (temperatura CPU/GPU quando disponível) - let thermal = ps(r#" - $temps = @() - # Tenta WMI thermal zone (requer admin em alguns sistemas) - try { - $zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue - foreach ($z in $zones) { - if ($z.CurrentTemperature) { - $celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1) - $temps += [PSCustomObject]@{ - Source = 'ThermalZone' - Name = $z.InstanceName - TemperatureCelsius = $celsius - CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null } - } - } - } - } catch {} - # CPU temp via Open Hardware Monitor WMI (se instalado) - try { - $ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' } - foreach ($s in $ohm) { - $temps += [PSCustomObject]@{ - Source = 'OpenHardwareMonitor' - Name = $s.Name - TemperatureCelsius = $s.Value - Parent = $s.Parent - } - } - } catch {} - @($temps) - "#).unwrap_or_else(|| json!([])); - - // Adaptadores de rede (físicos e virtuais) - let network_adapters = ps(r#" - @(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object { - $statusMap = @{ - 0 = 'Disconnected' - 1 = 'Connecting' - 2 = 'Connected' - 3 = 'Disconnecting' - 4 = 'Hardware not present' - 5 = 'Hardware disabled' - 6 = 'Hardware malfunction' - 7 = 'Media disconnected' - 8 = 'Authenticating' - 9 = 'Authentication succeeded' - 10 = 'Authentication failed' - 11 = 'Invalid address' - 12 = 'Credentials required' - } - $_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force - $_ - }) - "#).unwrap_or_else(|| json!([])); - - // Monitores conectados - let monitors = ps(r#" - @(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object { - $decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } } - [PSCustomObject]@{ - ManufacturerName = & $decode $_.ManufacturerName - ProductCodeID = & $decode $_.ProductCodeID - SerialNumberID = & $decode $_.SerialNumberID - UserFriendlyName = & $decode $_.UserFriendlyName - YearOfManufacture = $_.YearOfManufacture - WeekOfManufacture = $_.WeekOfManufacture - } - }) - "#).unwrap_or_else(|| json!([])); - - // Fonte de alimentação / chassis - let power_supply = ps(r#" - $chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag - $chassisTypeMap = @{ - 1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop' - 5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable' - 9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station' - 13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box' - 17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis' - 20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis' - 23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis' - 30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable' - } - $types = @() - if ($chassis.ChassisTypes) { - foreach ($t in $chassis.ChassisTypes) { - $types += $chassisTypeMap[[int]$t] ?? "Type$t" - } - } - [PSCustomObject]@{ - ChassisTypes = $chassis.ChassisTypes - ChassisTypesText = $types - Manufacturer = $chassis.Manufacturer - SerialNumber = $chassis.SerialNumber - SMBIOSAssetTag = $chassis.SMBIOSAssetTag - } - "#).unwrap_or_else(|| json!({})); - - // Último reinício e contagem de boots - let boot_info = ps(r#" - $os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime - $lastBoot = $os.LastBootUpTime - - # Calcula uptime - $uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 } - - # Conta eventos de boot (ID 6005) - últimos 30 dias para performance - $startDate = (Get-Date).AddDays(-30) - $bootEvents = @() - $bootCount = 0 - try { - $events = Get-WinEvent -FilterHashtable @{ - LogName = 'System' - ID = 6005 - StartTime = $startDate - } -MaxEvents 50 -ErrorAction SilentlyContinue - $bootCount = @($events).Count - $bootEvents = @($events | Select-Object -First 10 | ForEach-Object { - @{ - TimeCreated = $_.TimeCreated.ToString('o') - Computer = $_.MachineName - } - }) - } catch {} - - [PSCustomObject]@{ - LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null } - UptimeSeconds = [math]::Round($uptime) - BootCountLast30Days = $bootCount - RecentBoots = $bootEvents - } - "#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] })); - json!({ "windows": { "software": software, @@ -1155,12 +992,6 @@ fn collect_windows_extended() -> serde_json::Value { "windowsUpdate": windows_update, "computerSystem": computer_system, "azureAdStatus": device_join, - "battery": battery, - "thermal": thermal, - "networkAdapters": network_adapters, - "monitors": monitors, - "chassis": power_supply, - "bootInfo": boot_info, } }) } @@ -1255,7 +1086,7 @@ pub fn collect_profile() -> Result { let system = collect_system(); let os_name = System::name() - .or_else(System::long_os_version) + .or_else(|| System::long_os_version()) .unwrap_or_else(|| "desconhecido".to_string()); let os_version = System::os_version(); let architecture = std::env::consts::ARCH.to_string(); @@ -1315,7 +1146,7 @@ async fn post_heartbeat( .into_owned(); let os = MachineOs { name: System::name() - .or_else(System::long_os_version) + .or_else(|| System::long_os_version()) .unwrap_or_else(|| "desconhecido".to_string()), version: System::os_version(), architecture: Some(std::env::consts::ARCH.to_string()), @@ -1394,8 +1225,7 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) { #[cfg(target_os = "windows")] { - use crate::usb_control::{get_current_policy, UsbPolicy}; - use crate::service_client; + use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy}; let policy = match UsbPolicy::from_str(&policy_str) { Some(p) => p, @@ -1429,58 +1259,24 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) { // Reporta APPLYING para progress bar real no frontend let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await; - // Tenta primeiro via RavenService (privilegiado) - crate::log_info!("Tentando aplicar politica via RavenService..."); - match service_client::apply_usb_policy(&policy_str) { + match apply_usb_policy(policy) { Ok(result) => { - if result.success { - crate::log_info!("Politica USB aplicada com sucesso via RavenService: {:?}", result); - let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; - if !reported { - crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); - let base_url = base_url.to_string(); - let token = token.to_string(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - crate::log_info!("Retry agendado: reportando politica USB..."); - let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await; - }); - } - return; - } else { - let err_msg = result.error.unwrap_or_else(|| "Erro desconhecido".to_string()); - crate::log_error!("RavenService retornou erro: {}", err_msg); - report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await; - } - } - Err(service_client::ServiceClientError::ServiceUnavailable(msg)) => { - crate::log_warn!("RavenService nao disponivel: {}", msg); - // Tenta fallback direto (vai falhar se nao tiver privilegio) - crate::log_info!("Tentando aplicar politica diretamente..."); - match crate::usb_control::apply_usb_policy(policy) { - Ok(result) => { - crate::log_info!("Politica USB aplicada com sucesso (direto): {:?}", result); - let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; - if !reported { - crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); - let base_url = base_url.to_string(); - let token = token.to_string(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - crate::log_info!("Retry agendado: reportando politica USB..."); - let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await; - }); - } - } - Err(e) => { - let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e); - crate::log_error!("{}", err_msg); - report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await; - } + crate::log_info!("Politica USB aplicada com sucesso: {:?}", result); + let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; + if !reported { + crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); + // Agenda retry em background + let base_url = base_url.to_string(); + let token = token.to_string(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + crate::log_info!("Retry agendado: reportando politica USB..."); + let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await; + }); } } Err(e) => { - crate::log_error!("Falha ao comunicar com RavenService: {e}"); + crate::log_error!("Falha ao aplicar politica USB: {e}"); report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await; } } diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 7c924de..d2e52f3 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -11,11 +11,9 @@ use parking_lot::Mutex; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use std::fs; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; @@ -102,77 +100,6 @@ pub struct SessionStartedEvent { pub session: ChatSession, } -// ============================================================================ -// PERSISTENCIA DE ESTADO -// ============================================================================ - -/// Estado persistido do chat para sobreviver a restarts -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChatPersistedState { - last_unread_count: u32, - sessions: Vec, - 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 // ============================================================================ @@ -535,16 +462,10 @@ pub struct ChatRuntime { impl ChatRuntime { pub fn new() -> Self { - // Tentar restaurar estado persistido - let (sessions, unread) = match load_chat_state() { - Some(state) => (state.sessions, state.last_unread_count), - None => (Vec::new(), 0), - }; - Self { inner: Arc::new(Mutex::new(None)), - last_sessions: Arc::new(Mutex::new(sessions)), - last_unread_count: Arc::new(Mutex::new(unread)), + last_sessions: Arc::new(Mutex::new(Vec::new())), + last_unread_count: Arc::new(Mutex::new(0)), is_connected: Arc::new(AtomicBool::new(false)), } } @@ -589,9 +510,7 @@ impl ChatRuntime { let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat"); - crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone); - crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone); + crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); let mut backoff_ms: u64 = 1_000; let max_backoff_ms: u64 = 30_000; @@ -603,16 +522,12 @@ impl ChatRuntime { break; } - crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex..."); let client_result = ConvexClient::new(&convex_clone).await; let mut client = match client_result { - Ok(c) => { - crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso"); - c - } + Ok(c) => c, Err(err) => { is_connected.store(false, Ordering::Relaxed); - crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}"); + crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -635,18 +550,16 @@ impl ChatRuntime { let mut args = BTreeMap::new(); args.insert("machineToken".to_string(), token_clone.clone().into()); - crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates..."); let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await; let mut subscription = match subscribe_result { Ok(sub) => { is_connected.store(true, Ordering::Relaxed); backoff_ms = 1_000; - crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!"); sub } Err(err) => { is_connected.store(false, Ordering::Relaxed); - crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}"); + crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( @@ -666,12 +579,8 @@ impl ChatRuntime { } }; - crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket..."); - let mut update_count: u64 = 0; while let Some(next) = subscription.next().await { - update_count += 1; if stop_clone.load(Ordering::Relaxed) { - crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop"); break; } match next { @@ -692,11 +601,6 @@ impl ChatRuntime { }) .unwrap_or(0); - crate::log_info!( - "[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}", - update_count, has_active, total_unread - ); - process_chat_update( &base_clone, &token_clone, @@ -709,13 +613,13 @@ impl ChatRuntime { .await; } FunctionResult::ConvexError(err) => { - crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}"); + crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); } FunctionResult::ErrorMessage(msg) => { - crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}"); + crate::log_warn!("Erro em checkMachineUpdates: {msg}"); } FunctionResult::Value(other) => { - crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}"); + crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); } } } @@ -723,11 +627,10 @@ impl ChatRuntime { is_connected.store(false, Ordering::Relaxed); if stop_clone.load(Ordering::Relaxed) { - crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop"); break; } - crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar..."); + crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, @@ -781,13 +684,8 @@ 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, @@ -800,7 +698,7 @@ async fn poll_and_process_chat_update( .await; } Err(err) => { - crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}"); + crate::log_warn!("Chat fallback poll falhou: {err}"); } } } @@ -814,18 +712,10 @@ async fn process_chat_update( has_active_sessions: bool, total_unread: u32, ) { - crate::log_info!( - "[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}", - has_active_sessions, total_unread - ); - // Buscar sessoes completas para ter dados corretos let mut current_sessions = if has_active_sessions { - let sessions = fetch_sessions(base_url, token).await.unwrap_or_default(); - crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len()); - sessions + fetch_sessions(base_url, token).await.unwrap_or_default() } else { - crate::log_info!("[CHAT DEBUG] Sem sessoes ativas"); Vec::new() }; @@ -886,57 +776,13 @@ async fn process_chat_update( } } - // ========================================================================= - // DETECCAO ROBUSTA DE NOVAS MENSAGENS - // Usa DUAS estrategias: timestamp E contador (belt and suspenders) - // ========================================================================= - - let prev_unread = *last_unread_count.lock(); - - // Estrategia 1: Detectar por lastActivityAt de cada sessao - // Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem - let mut detected_by_activity = false; - let mut activity_details = String::new(); - - for session in ¤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) + // Atualizar cache de sessoes *last_sessions.lock() = current_sessions.clone(); - *last_unread_count.lock() = total_unread; - // Persistir estado para sobreviver a restarts - save_chat_state(total_unread, ¤t_sessions); + // Verificar mensagens nao lidas + let prev_unread = *last_unread_count.lock(); + let new_messages = total_unread > prev_unread; + *last_unread_count.lock() = total_unread; // Sempre emitir unread-update let _ = app.emit( @@ -949,17 +795,9 @@ async fn process_chat_update( // Notificar novas mensagens - mostrar chat minimizado com badge if new_messages && total_unread > 0 { - let new_count = if total_unread > prev_unread { - total_unread - prev_unread - } else { - 1 // Se detectou por activity mas contador nao mudou, assumir 1 nova - }; + let new_count = total_unread - prev_unread; - crate::log_info!( - "[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}", - new_count, total_unread, - if detected_by_activity { "activity" } else { "count" } - ); + crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread); let _ = app.emit( "raven://chat/new-message", @@ -1000,30 +838,37 @@ async fn process_chat_update( } } - // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual. - // - // Importante (UX): em multiplas sessoes, NAO fecha a janela ativa quando chega mensagem em outra conversa. - // O hub + badge/notificacao sinalizam novas mensagens e o usuario decide quando alternar. - if current_sessions.len() > 1 { - let _ = open_hub_window(app); + // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. + let session_to_show = if best_delta > 0 { + best_session } else { - // Uma sessao - nao precisa de hub - let _ = close_hub_window(app); + current_sessions.iter().max_by(|a, b| { + a.unread_count + .cmp(&b.unread_count) + .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) + }) + }; - // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. - let session_to_show = if best_delta > 0 { - best_session + // Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) + if let Some(session) = session_to_show { + let label = format!("chat-{}", session.ticket_id); + if let Some(window) = app.get_webview_window(&label) { + // Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida) + // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens + let _ = window.show(); + // Verificar se esta expandida (altura > 100px significa expandido) + // Se estiver expandida, NAO minimizar - usuario esta usando o chat + if let Ok(size) = window.inner_size() { + let is_expanded = size.height > 100; + if !is_expanded { + // Janela esta minimizada, manter minimizada + let _ = set_chat_minimized(app, &session.ticket_id, true); + } + // Se esta expandida, nao faz nada - deixa o usuario continuar usando + } } else { - current_sessions.iter().max_by(|a, b| { - a.unread_count - .cmp(&b.unread_count) - .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) - }) - }; - - // Mostrar janela de chat (sempre minimizada/nao intrusiva) - if let Some(session) = session_to_show { - let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true); + // Criar nova janela ja minimizada (menos intrusivo) + let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); } } @@ -1040,16 +885,6 @@ 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 - ); - } } } @@ -1057,24 +892,6 @@ async fn process_chat_update( // WINDOW MANAGEMENT // ============================================================================ -// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2). -static WINDOW_OP_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - -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(); - } - 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>, @@ -1115,41 +932,18 @@ fn resolve_chat_window_position( (x, y) } -fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { - let _guard = WINDOW_OP_LOCK.lock(); - open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized) +fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { + open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada } /// Abre janela de chat com estado inicial de minimizacao configuravel fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); - crate::log_info!( - "[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}", - label, - ticket_ref, - start_minimized - ); - - if !start_minimized { - hide_other_chat_windows(app, &label); - } // Verificar se ja existe if let Some(window) = app.get_webview_window(&label) { - let _ = window.set_ignore_cursor_events(false); - crate::log_info!("[WINDOW] {}: window existe -> show()", label); window.show().map_err(|e| e.to_string())?; - let _ = window.unminimize(); - if !start_minimized { - crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label); - window.set_focus().map_err(|e| e.to_string())?; - } - // Expandir a janela se estiver minimizada (quando clicado na lista) - if !start_minimized { - crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label); - let _ = set_chat_minimized_unlocked(app, ticket_id, false); - } - crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label); + window.set_focus().map_err(|e| e.to_string())?; return Ok(()); } @@ -1166,17 +960,7 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r // Usar query param ao inves de path para compatibilidade com SPA let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref); - crate::log_info!( - "[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}", - label, - width, - height, - x, - y, - url_path - ); - - let window = WebviewWindowBuilder::new( + WebviewWindowBuilder::new( app, &label, WebviewUrl::App(url_path.into()), @@ -1188,37 +972,26 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r .decorations(false) // Sem decoracoes nativas - usa header customizado .transparent(true) // Permite fundo transparente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente - .resizable(false) // Desabilitar redimensionamento manual - // Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true). .always_on_top(true) .skip_taskbar(true) - .focused(!start_minimized) + .focused(true) .visible(true) .build() .map_err(|e| e.to_string())?; - crate::log_info!("[WINDOW] {}: build() OK", label); - - // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) - let _ = window.set_ignore_cursor_events(false); - - crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized); // Reaplica layout/posicao logo apos criar a janela. // Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes. - let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized); - crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized); + let _ = set_chat_minimized(app, ticket_id, start_minimized); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label); Ok(()) } pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { - // Quando chamado explicitamente (ex: clique no hub), abre expandida - open_chat_window_internal(app, ticket_id, ticket_ref, false) + open_chat_window_internal(app, ticket_id, ticket_ref) } pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { - let _guard = WINDOW_OP_LOCK.lock(); let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.close().map_err(|e| e.to_string())?; @@ -1227,7 +1000,6 @@ pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), } 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())?; @@ -1236,14 +1008,10 @@ pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<( } /// Redimensiona a janela de chat para modo minimizado (chip) ou expandido -fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { +pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; - if minimized { - hide_other_chat_windows(app, &label); - } - // Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1) let (width, height) = if minimized { (240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge @@ -1255,124 +1023,9 @@ fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimize let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); // Aplicar novo tamanho e posicao - crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized); window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; - crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized); - crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized); window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; - crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized); 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 5e3939e..ece1ae8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,8 +2,6 @@ mod agent; mod chat; #[cfg(target_os = "windows")] mod rustdesk; -#[cfg(target_os = "windows")] -mod service_client; mod usb_control; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; @@ -70,21 +68,21 @@ pub fn log_agent(level: &str, message: &str) { #[macro_export] macro_rules! log_info { ($($arg:tt)*) => { - $crate::log_agent("INFO", format!($($arg)*).as_str()) + $crate::log_agent("INFO", &format!($($arg)*)) }; } #[macro_export] macro_rules! log_error { ($($arg:tt)*) => { - $crate::log_agent("ERROR", format!($($arg)*).as_str()) + $crate::log_agent("ERROR", &format!($($arg)*)) }; } #[macro_export] macro_rules! log_warn { ($($arg:tt)*) => { - $crate::log_agent("WARN", format!($($arg)*).as_str()) + $crate::log_agent("WARN", &format!($($arg)*)) }; } @@ -191,32 +189,6 @@ fn run_rustdesk_ensure( password: Option, 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(), @@ -236,50 +208,14 @@ fn run_rustdesk_ensure( #[tauri::command] fn apply_usb_policy(policy: String) -> Result { - // Valida a politica primeiro - let _policy_enum = UsbPolicy::from_str(&policy) + let policy_enum = UsbPolicy::from_str(&policy) .ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?; - // Tenta usar o servico primeiro (sem UAC) - #[cfg(target_os = "windows")] - if service_client::is_service_available() { - log_info!("Usando Raven Service para aplicar politica USB: {}", policy); - match service_client::apply_usb_policy(&policy) { - Ok(result) => { - return Ok(UsbPolicyResult { - success: result.success, - policy: result.policy, - error: result.error, - applied_at: result.applied_at, - }); - } - Err(e) => { - log_warn!("Falha ao usar servico para USB policy: {e}"); - // Continua para fallback - } - } - } - - // Fallback: chamada direta (pode pedir UAC) - log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)"); - usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string()) + usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string()) } #[tauri::command] fn get_usb_policy() -> Result { - // Tenta usar o servico primeiro - #[cfg(target_os = "windows")] - if service_client::is_service_available() { - match service_client::get_usb_policy() { - Ok(policy) => return Ok(policy), - Err(e) => { - log_warn!("Falha ao obter USB policy via servico: {e}"); - // Continua para fallback - } - } - } - - // Fallback: leitura direta (nao precisa elevacao para ler) usb_control::get_current_policy() .map(|p| p.as_str().to_string()) .map_err(|e| e.to_string()) @@ -410,17 +346,8 @@ async fn upload_chat_file( } #[tauri::command] -async fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> { - log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref); - let app_handle = app.clone(); - let ticket_id_for_task = ticket_id.clone(); - let result = tauri::async_runtime::spawn_blocking(move || { - chat::open_chat_window(&app_handle, &ticket_id_for_task, ticket_ref) - }) - .await - .map_err(|err| format!("Falha ao abrir chat (join): {err}"))?; - log_info!("[CMD] open_chat_window result: {:?}", result); - result +fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> { + chat::open_chat_window(&app, &ticket_id, ticket_ref) } #[tauri::command] @@ -438,26 +365,6 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool) chat::set_chat_minimized(&app, &ticket_id, minimized) } -#[tauri::command] -async fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> { - let app_handle = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - chat::open_hub_window(&app_handle) - }) - .await - .map_err(|err| format!("Falha ao abrir hub (join): {err}"))? -} - -#[tauri::command] -fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> { - chat::close_hub_window(&app) -} - -#[tauri::command] -fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> { - chat::set_hub_minimized(&app, minimized) -} - // ============================================================================ // Handler de Deep Link (raven://) // ============================================================================ @@ -545,14 +452,6 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - // Quando uma segunda instância tenta iniciar, foca a janela existente - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } - })) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -582,7 +481,7 @@ pub fn run() { { let start_in_background = std::env::args().any(|arg| arg == "--background"); setup_raven_autostart(); - setup_tray(app.handle())?; + setup_tray(&app.handle())?; if start_in_background { if let Some(win) = app.get_webview_window("main") { let _ = win.hide(); @@ -627,11 +526,7 @@ pub fn run() { open_chat_window, close_chat_window, minimize_chat_window, - set_chat_minimized, - // Hub commands - open_hub_window, - close_hub_window, - set_hub_minimized + set_chat_minimized ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -713,13 +608,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { // Abrir janela de chat se houver sessao ativa if let Some(chat_runtime) = tray.app_handle().try_state::() { let sessions = chat_runtime.get_sessions(); - if sessions.len() > 1 { - // Multiplas sessoes - abrir hub - if let Err(e) = chat::open_hub_window(tray.app_handle()) { - log_error!("Falha ao abrir hub de chat: {e}"); - } - } else if let Some(session) = sessions.first() { - // Uma sessao - abrir diretamente + if let Some(session) = sessions.first() { if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) { log_error!("Falha ao abrir janela de chat: {e}"); } diff --git a/apps/desktop/src-tauri/src/rustdesk.rs b/apps/desktop/src-tauri/src/rustdesk.rs index 8c6cee4..ef4a81f 100644 --- a/apps/desktop/src-tauri/src/rustdesk.rs +++ b/apps/desktop/src-tauri/src/rustdesk.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "windows")] + use crate::RustdeskProvisioningResult; use chrono::{Local, Utc}; use once_cell::sync::Lazy; @@ -28,9 +30,7 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\ const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config"; const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados"; const MACHINE_STORE_FILENAME: &str = "machine-agent.json"; -#[allow(dead_code)] const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag"; -#[allow(dead_code)] const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt"; const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password"; const SECURITY_APPROVE_MODE_VALUE: &str = "password"; @@ -85,11 +85,11 @@ fn define_custom_id_from_machine(exe_path: &Path, machine_id: Option<&str>) -> O }) { match set_custom_id(exe_path, value) { Ok(custom) => { - log_event(format!("ID determinístico definido: {custom}")); + log_event(&format!("ID determinístico definido: {custom}")); Some(custom) } Err(error) => { - log_event(format!("Falha ao definir ID determinístico: {error}")); + log_event(&format!("Falha ao definir ID determinístico: {error}")); None } } @@ -107,7 +107,7 @@ pub fn ensure_rustdesk( log_event("Iniciando preparo do RustDesk"); if let Err(error) = ensure_service_profiles_writable_preflight() { - log_event(format!( + log_event(&format!( "Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha." )); } @@ -116,7 +116,7 @@ pub fn ensure_rustdesk( // Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece let preserved_remote_id = read_remote_id_from_profiles(); if let Some(ref id) = preserved_remote_id { - log_event(format!("ID existente preservado antes da limpeza: {}", id)); + log_event(&format!("ID existente preservado antes da limpeza: {}", id)); } let exe_path = detect_executable_path(); @@ -129,7 +129,7 @@ pub fn ensure_rustdesk( match stop_rustdesk_processes() { Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"), - Err(error) => log_event(format!( + Err(error) => log_event(&format!( "Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})" )), } @@ -139,7 +139,7 @@ pub fn ensure_rustdesk( if freshly_installed { match purge_existing_rustdesk_profiles() { Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"), - Err(error) => log_event(format!( + Err(error) => log_event(&format!( "Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})" )), } @@ -152,19 +152,19 @@ pub fn ensure_rustdesk( if trimmed.is_empty() { None } else { Some(trimmed) } }) { if let Err(error) = run_with_args(&exe_path, &["--config", value]) { - log_event(format!("Falha ao aplicar configuração inline: {error}")); + log_event(&format!("Falha ao aplicar configuração inline: {error}")); } else { log_event("Configuração aplicada via --config"); } } else { let config_path = write_config_files()?; - log_event(format!( + log_event(&format!( "Arquivo de configuração atualizado em {}", config_path.display() )); if let Err(error) = apply_config(&exe_path, &config_path) { - log_event(format!("Falha ao aplicar configuração via CLI: {error}")); + log_event(&format!("Falha ao aplicar configuração via CLI: {error}")); } else { log_event("Configuração aplicada via CLI"); } @@ -176,7 +176,7 @@ pub fn ensure_rustdesk( .unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); if let Err(error) = set_password(&exe_path, &password) { - log_event(format!("Falha ao definir senha padrão: {error}")); + log_event(&format!("Falha ao definir senha padrão: {error}")); } else { log_event("Senha padrão definida com sucesso"); log_event("Aplicando senha nos perfis do RustDesk"); @@ -185,21 +185,21 @@ pub fn ensure_rustdesk( log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk"); log_password_replication(&password); } - Err(error) => log_event(format!("Falha ao persistir senha nos perfis: {error}")), + Err(error) => log_event(&format!("Falha ao persistir senha nos perfis: {error}")), } match propagate_password_profile() { Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"), - Err(error) => log_event(format!("Falha ao copiar perfil de senha: {error}")), + Err(error) => log_event(&format!("Falha ao copiar perfil de senha: {error}")), } match replicate_password_artifacts() { Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"), - Err(error) => log_event(format!("Falha ao replicar artefatos de senha: {error}")), + Err(error) => log_event(&format!("Falha ao replicar artefatos de senha: {error}")), } if let Err(error) = enforce_security_flags() { - log_event(format!("Falha ao reforçar configuração de senha permanente: {error}")); + log_event(&format!("Falha ao reforçar configuração de senha permanente: {error}")); } } @@ -207,7 +207,7 @@ pub fn ensure_rustdesk( // Isso garante que reinstalar o Raven nao muda o ID do RustDesk let custom_id = if let Some(ref existing_id) = preserved_remote_id { if !freshly_installed { - log_event(format!("Reutilizando ID existente do RustDesk: {}", existing_id)); + log_event(&format!("Reutilizando ID existente do RustDesk: {}", existing_id)); Some(existing_id.clone()) } else { // Instalacao fresca - define novo ID baseado no machine_id @@ -219,7 +219,7 @@ pub fn ensure_rustdesk( }; if let Err(error) = ensure_service_running(&exe_path) { - log_event(format!("Falha ao reiniciar serviço do RustDesk: {error}")); + log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}")); } else { log_event("Serviço RustDesk reiniciado/run ativo"); } @@ -227,10 +227,10 @@ pub fn ensure_rustdesk( let reported_id = match query_id_with_retries(&exe_path, 5) { Ok(value) => value, Err(error) => { - log_event(format!("Falha ao obter ID após múltiplas tentativas: {error}")); + log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}")); match read_remote_id_from_profiles().or_else(|| custom_id.clone()) { Some(value) => { - log_event(format!("ID obtido via arquivos de perfil: {value}")); + log_event(&format!("ID obtido via arquivos de perfil: {value}")); value } None => return Err(error), @@ -242,7 +242,7 @@ pub fn ensure_rustdesk( if let Some(expected) = custom_id.as_ref() { if expected != &reported_id { - log_event(format!( + log_event(&format!( "ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico" )); @@ -252,25 +252,25 @@ pub fn ensure_rustdesk( Ok(_) => match query_id_with_retries(&exe_path, 3) { Ok(rechecked) => { if &rechecked == expected { - log_event(format!("ID determinístico aplicado com sucesso: {rechecked}")); + log_event(&format!("ID determinístico aplicado com sucesso: {rechecked}")); final_id = rechecked; enforced = true; } else { - log_event(format!( + log_event(&format!( "ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado" )); final_id = rechecked; } } Err(error) => { - log_event(format!( + log_event(&format!( "Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})" )); final_id = reported_id.clone(); } }, Err(error) => { - log_event(format!( + log_event(&format!( "Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})" )); final_id = reported_id.clone(); @@ -308,7 +308,7 @@ pub fn ensure_rustdesk( "lastError": serde_json::Value::Null }); if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) { - log_event(format!("Aviso: falha ao salvar dados do RustDesk no store: {error}")); + log_event(&format!("Aviso: falha ao salvar dados do RustDesk no store: {error}")); } else { log_event("Dados do RustDesk salvos no machine-agent.json"); } @@ -316,7 +316,7 @@ pub fn ensure_rustdesk( // Sincroniza com o backend imediatamente apos provisionar // O Rust faz o HTTP direto, sem passar pelo CSP do webview if let Err(error) = sync_remote_access_with_backend(&result) { - log_event(format!("Aviso: falha ao sincronizar com backend: {error}")); + log_event(&format!("Aviso: falha ao sincronizar com backend: {error}")); } else { log_event("Acesso remoto sincronizado com backend"); // Atualiza lastSyncedAt no store @@ -330,13 +330,13 @@ pub fn ensure_rustdesk( "lastError": serde_json::Value::Null }); if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) { - log_event(format!("Aviso: falha ao atualizar lastSyncedAt: {e}")); + log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}")); } else { log_event("lastSyncedAt atualizado com sucesso"); } } - log_event(format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version)); + log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version)); Ok(result) } @@ -403,7 +403,7 @@ fn write_config_files() -> Result { 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,11 +981,13 @@ fn replicate_password_artifacts() -> io::Result<()> { fn purge_existing_rustdesk_profiles() -> Result<(), String> { let mut errors = Vec::new(); + let mut cleaned_any = false; for dir in remote_id_directories() { match purge_config_dir(&dir) { Ok(true) => { - log_event(format!( + cleaned_any = true; + log_event(&format!( "Perfis antigos removidos em {}", dir.display() )); @@ -995,7 +997,9 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> { } } - if errors.is_empty() { + if cleaned_any { + Ok(()) + } else if errors.is_empty() { Ok(()) } else { Err(errors.join(" | ")) @@ -1026,7 +1030,6 @@ fn purge_config_dir(dir: &Path) -> Result { Ok(removed) } -#[allow(dead_code)] fn run_powershell_elevated(script: &str) -> Result<(), String> { let temp_dir = env::temp_dir(); let payload = temp_dir.join("raven_payload.ps1"); @@ -1074,7 +1077,6 @@ exit $process.ExitCode Err(format!("elevated ps exit {:?}", status.code())) } -#[allow(dead_code)] fn fix_profile_acl(target: &Path) -> Result<(), String> { let target_str = target.display().to_string(); let transcript = env::temp_dir().join("raven_acl_ps.log"); @@ -1109,7 +1111,7 @@ try {{ let result = run_powershell_elevated(&script); if result.is_err() { if let Ok(content) = fs::read_to_string(&transcript) { - log_event(format!( + log_event(&format!( "ACL transcript para {}:\n{}", target.display(), content )); @@ -1120,9 +1122,6 @@ try {{ } fn ensure_service_profiles_writable_preflight() -> Result<(), String> { - // Verificamos se os diretorios de perfil sao graváveis - // Se nao forem, apenas logamos aviso - o Raven Service deve lidar com isso - // Nao usamos elevacao para evitar UAC adicional let mut blocked_dirs = Vec::new(); for dir in service_profile_dirs() { if !can_write_dir(&dir) { @@ -1134,46 +1133,53 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> { return Ok(()); } - // Apenas logamos aviso - o serviço RavenService deve lidar com permissões - log_event(format!( - "Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.", - blocked_dirs.iter().map(|d| d.display().to_string()).collect::>() - )); + if has_acl_unlock_flag() { + log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL"); + } else { + log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)"); + } - // Retornamos Ok para não bloquear o fluxo - // O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios - Ok(()) + let mut last_error: Option = None; + for dir in blocked_dirs.iter() { + log_event(&format!( + "Tentando corrigir ACL via UAC (preflight) em {}...", + dir.display() + )); + if let Err(error) = fix_profile_acl(dir) { + last_error = Some(error); + continue; + } + if can_write_dir(dir) { + log_event(&format!( + "ACL ajustada com sucesso em {}", + dir.display() + )); + } else { + last_error = Some(format!( + "continua sem permissão para {} mesmo após preflight", + dir.display() + )); + } + } + + if blocked_dirs.iter().all(|dir| can_write_dir(dir)) { + mark_acl_unlock_flag(); + Ok(()) + } else { + Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into())) + } } fn stop_service_elevated() -> Result<(), String> { - // Tentamos parar o serviço RustDesk sem elevação - // Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso - // Não usamos elevação para evitar UAC adicional - let output = Command::new("sc") - .args(["stop", "RustDesk"]) - .output(); - - match output { - Ok(result) => { - if result.status.success() { - // Aguarda um pouco para o serviço parar - std::thread::sleep(std::time::Duration::from_secs(2)); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&result.stderr); - log_event(format!( - "Aviso: não foi possível parar o serviço RustDesk sem elevação: {}", - stderr.trim() - )); - // Retornamos Ok para não bloquear - o serviço pode estar já parado - Ok(()) - } - } - Err(e) => { - log_event(format!("Aviso: falha ao executar sc stop RustDesk: {e}")); - Ok(()) - } - } + let script = r#" +$ErrorActionPreference='Stop' +$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue +if ($service -and $service.Status -ne 'Stopped') { + Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop + $service.WaitForStatus('Stopped','00:00:10') +} +"#; + run_powershell_elevated(script) } fn can_write_dir(dir: &Path) -> bool { @@ -1333,21 +1339,21 @@ fn log_password_replication(secret: &str) { fn log_password_match(path: &Path, secret: &str) { match read_password_from_file(path) { Some(value) if value == secret => { - log_event(format!( + log_event(&format!( "Senha confirmada em {} ({})", path.display(), mask_secret(&value) )); } Some(value) => { - log_event(format!( + log_event(&format!( "Aviso: senha divergente ({}) em {}", mask_secret(&value), path.display() )); } None => { - log_event(format!( + log_event(&format!( "Aviso: chave 'password' não encontrada em {}", path.display() )); @@ -1463,24 +1469,21 @@ fn write_machine_store_object(map: JsonMap) -> Result<(), Str } fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> { - let mut map = read_machine_store_object().unwrap_or_default(); + let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new); map.insert(key.to_string(), value); write_machine_store_object(map) } -#[allow(dead_code)] fn machine_store_key_exists(key: &str) -> bool { read_machine_store_object() .map(|map| map.contains_key(key)) .unwrap_or(false) } -#[allow(dead_code)] fn acl_flag_file_path() -> Option { raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME)) } -#[allow(dead_code)] fn has_acl_unlock_flag() -> bool { if let Some(flag) = acl_flag_file_path() { if flag.exists() { @@ -1490,7 +1493,6 @@ fn has_acl_unlock_flag() -> bool { machine_store_key_exists(RUSTDESK_ACL_STORE_KEY) } -#[allow(dead_code)] fn mark_acl_unlock_flag() { let timestamp = Utc::now().timestamp_millis(); if let Some(flag_path) = acl_flag_file_path() { @@ -1498,7 +1500,7 @@ fn mark_acl_unlock_flag() { let _ = fs::create_dir_all(parent); } if let Err(error) = fs::write(&flag_path, timestamp.to_string()) { - log_event(format!( + log_event(&format!( "Falha ao gravar flag de ACL em {}: {error}", flag_path.display() )); @@ -1506,7 +1508,7 @@ fn mark_acl_unlock_flag() { } if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) { - log_event(format!( + log_event(&format!( "Falha ao registrar flag de ACL no machine-agent: {error}" )); } @@ -1545,7 +1547,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) - .and_then(|v| v.as_str()) .unwrap_or("https://tickets.esdrasrenan.com.br"); - log_event(format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id)); + log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id)); // Monta payload conforme schema esperado pelo backend // Schema: { machineToken, provider, identifier, password?, url?, username?, notes? } @@ -1573,13 +1575,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) - .send()?; if response.status().is_success() { - log_event(format!("Sync com backend OK: status {}", response.status())); + log_event(&format!("Sync com backend OK: status {}", response.status())); Ok(()) } else { let status = response.status(); let body = response.text().unwrap_or_default(); let body_preview = if body.len() > 200 { &body[..200] } else { &body }; - log_event(format!("Sync com backend falhou: {} - {}", status, body_preview)); + log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview)); Err(RustdeskError::CommandFailed { command: "sync_remote_access".to_string(), status: Some(status.as_u16() as i32) diff --git a/apps/desktop/src-tauri/src/service_client.rs b/apps/desktop/src-tauri/src/service_client.rs deleted file mode 100644 index f2af2ed..0000000 --- a/apps/desktop/src-tauri/src/service_client.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Cliente IPC para comunicacao com o Raven Service -//! -//! Este modulo permite que o app Tauri se comunique com o Raven Service -//! via Named Pipes para executar operacoes privilegiadas. - -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; -use std::io::{BufRead, BufReader, Write}; -use std::time::Duration; -use thiserror::Error; - -const PIPE_NAME: &str = r"\\.\pipe\RavenService"; - -#[derive(Debug, Error)] -pub enum ServiceClientError { - #[error("Servico nao disponivel: {0}")] - ServiceUnavailable(String), - - #[error("Erro de comunicacao: {0}")] - CommunicationError(String), - - #[error("Erro de serializacao: {0}")] - SerializationError(#[from] serde_json::Error), - - #[error("Erro do servico: {message} (code: {code})")] - ServiceError { code: i32, message: String }, - - #[error("Timeout aguardando resposta")] - Timeout, -} - -#[derive(Debug, Serialize)] -struct Request { - id: String, - method: String, - params: serde_json::Value, -} - -#[derive(Debug, Deserialize)] -struct Response { - id: String, - result: Option, - 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 a95e0a5..24eebfa 100644 --- a/apps/desktop/src-tauri/src/usb_control.rs +++ b/apps/desktop/src-tauri/src/usb_control.rs @@ -93,10 +93,22 @@ mod windows_impl { applied_at: Some(now), }), Err(err) => { - // Se faltou permissão, retorna erro - o serviço deve ser usado - // Não fazemos elevação aqui para evitar UAC adicional + // Tenta elevação se faltou permissão if is_permission_error(&err) { - return Err(UsbControlError::PermissionDenied); + if let Err(elevated_err) = apply_policy_with_elevation(policy) { + return Err(elevated_err); + } + // Revalida a policy após elevação + let current = get_current_policy()?; + if current != policy { + return Err(UsbControlError::PermissionDenied); + } + return Ok(UsbPolicyResult { + success: true, + policy: policy.as_str().to_string(), + error: None, + applied_at: Some(now), + }); } Err(err) } @@ -207,8 +219,10 @@ mod windows_impl { key.set_value("WriteProtect", &1u32) .map_err(map_winreg_error)?; - } else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { - let _ = key.set_value("WriteProtect", &0u32); + } else { + if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { + let _ = key.set_value("WriteProtect", &0u32); + } } Ok(()) @@ -255,7 +269,6 @@ mod windows_impl { } } - #[allow(dead_code)] fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> { // Cria script temporário para aplicar as chaves via PowerShell elevado let temp_dir = std::env::temp_dir(); @@ -308,7 +321,7 @@ try {{ policy = policy_str ); - fs::write(&script_path, script).map_err(UsbControlError::Io)?; + fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?; // Start-Process com RunAs para acionar UAC let arg = format!( @@ -320,7 +333,7 @@ try {{ .arg("-Command") .arg(arg) .status() - .map_err(UsbControlError::Io)?; + .map_err(|e| UsbControlError::Io(e))?; if !status.success() { return Err(UsbControlError::PermissionDenied); @@ -349,7 +362,7 @@ try {{ .args(["/target:computer", "/force"]) .creation_flags(CREATE_NO_WINDOW) .output() - .map_err(UsbControlError::Io)?; + .map_err(|e| UsbControlError::Io(e))?; if !output.status.success() { // Nao e critico se falhar, apenas log diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b9a94d1..6c9fa49 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,12 +50,10 @@ "icons/icon.png", "icons/Raven.png" ], - "resources": { - "../service/target/release/raven-service.exe": "raven-service.exe" - }, "windows": { "webviewInstallMode": { - "type": "skip" + "type": "downloadBootstrapper", + "silent": true }, "nsis": { "displayLanguageSelector": true, diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx deleted file mode 100644 index 04358d9..0000000 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/** - * ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions - * - * Arquitetura: - * - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro) - * - Sem polling - todas as atualizacoes sao push-based via WebSocket - * - Tauri usado apenas para gerenciamento de janelas - */ - -import { useEffect, useState } from "react" -import { invoke } from "@tauri-apps/api/core" -import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react" -import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries" - -/** - * Hub Widget - Lista todas as sessoes de chat ativas - * Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket - */ -export function ChatHubWidget() { - // Inicializa baseado na altura real da janela (< 100px = minimizado) - const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100) - - // Convex subscription reativa - const { sessions = [], isLoading, hasToken } = useMachineSessions() - - // Sincronizar estado minimizado com tamanho da janela - useEffect(() => { - const mountTime = Date.now() - const STABILIZATION_DELAY = 500 - - const handler = () => { - if (Date.now() - mountTime < STABILIZATION_DELAY) { - return - } - const h = window.innerHeight - setIsMinimized(h < 100) - } - window.addEventListener("resize", handler) - return () => window.removeEventListener("resize", handler) - }, []) - - const handleSelectSession = async (ticketId: string, ticketRef: number) => { - try { - // Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS) - await invoke("open_chat_window", { ticketId, ticketRef }) - await invoke("close_hub_window") - } catch (err) { - console.error("open_chat_window FAILED:", err) - } - } - - const handleMinimize = async () => { - setIsMinimized(true) - try { - await invoke("set_hub_minimized", { minimized: true }) - } catch (err) { - console.error("Erro ao minimizar hub:", err) - } - } - - const handleExpand = async () => { - try { - await invoke("set_hub_minimized", { minimized: false }) - setTimeout(() => setIsMinimized(false), 100) - } catch (err) { - console.error("set_hub_minimized FAILED:", err) - setIsMinimized(false) - } - } - - const handleClose = () => { - invoke("close_hub_window").catch((err) => { - console.error("Erro ao fechar janela do hub:", err) - }) - } - - const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0) - - // Sem token - if (!hasToken) { - return ( -
-
- 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 c60ae67..e4964fc 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,26 +1,25 @@ -/** - * 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 { 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" +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" -const MAX_MESSAGES_IN_MEMORY = 200 +const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak 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", @@ -33,13 +32,6 @@ 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)) { @@ -65,7 +57,7 @@ function formatAttachmentSize(size?: number) { return `${(kb / 1024).toFixed(1)}MB` } -function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] { +function getUnreadAgentMessageIds(messages: ChatMessage[], 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--) { @@ -146,6 +138,7 @@ 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) @@ -167,7 +160,7 @@ function MessageAttachment({ if (isImage && url) { return (
- {/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */} + {/* eslint-disable-next-line @next/next/no-img-element */} {attachment.name} @@ -186,7 +179,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" - aria-label="Baixar anexo" + title="Baixar" > {downloading ? ( @@ -204,11 +197,7 @@ function MessageAttachment({ return (
{getFileIcon(attachment.name)} - {sizeLabel && ({sizeLabel})} @@ -216,7 +205,7 @@ function MessageAttachment({ @@ -224,7 +213,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"}`} - aria-label="Baixar anexo" + title="Baixar" > {downloading ? ( @@ -245,30 +234,24 @@ 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 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]) + // Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente + const [isMinimized, setIsMinimized] = useState(true) + const [unreadCount, setUnreadCount] = useState(0) 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) @@ -277,28 +260,10 @@ 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 @@ -323,39 +288,43 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { return true }, [updateIsAtBottom]) - // Fechar janela quando sessao termina + // Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso useEffect(() => { const prevHasSession = prevHasSessionRef.current if (prevHasSession && !hasSession) { invoke("close_chat_window", { ticketId }).catch((err) => { - console.error("Erro ao fechar janela ao encerrar sessao:", err) + console.error("Erro ao fechar janela ao encerrar sessão:", err) }) } prevHasSessionRef.current = hasSession }, [hasSession, ticketId]) - // Ref para acessar isMinimized dentro de callbacks + // Ref para acessar isMinimized dentro do callback sem causar resubscription const isMinimizedRef = useRef(isMinimized) useEffect(() => { isMinimizedRef.current = isMinimized }, [isMinimized]) - // Cache de URLs de anexos + const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) + + const ensureConfig = useCallback(async () => { + const cfg = configRef.current ?? (await getMachineStoreConfig()) + configRef.current = cfg + return cfg + }, []) + const attachmentUrlCacheRef = useRef>(new Map()) const loadAttachmentUrl = useCallback(async (storageId: string) => { const cached = attachmentUrlCacheRef.current.get(storageId) if (cached) return cached - if (!apiBaseUrl || !machineToken) { - throw new Error("Configuracao nao disponivel") - } - - const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, { + const cfg = await ensureConfig() + const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - machineToken, + machineToken: cfg.token, ticketId, storageId, }), @@ -373,50 +342,148 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { attachmentUrlCacheRef.current.set(storageId, data.url) return data.url - }, [apiBaseUrl, machineToken, ticketId]) + }, [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]) const markUnreadMessagesRead = useCallback(async () => { - if (unreadCount <= 0) return false + if (unreadCount <= 0) return const ids = getUnreadAgentMessageIds(messages, unreadCount) - if (ids.length === 0) return false + if (ids.length === 0) return + const cfg = await ensureConfig() const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE) + for (const chunk of chunks) { - await markMessagesRead({ - ticketId: ticketId as Id<"tickets">, - messageIds: chunk as Id<"ticketChatMessages">[], + await invoke("mark_chat_messages_read", { + baseUrl: cfg.apiBaseUrl, + token: cfg.token, + ticketId, + messageIds: chunk, }) } - return true - }, [messages, ticketId, unreadCount, markMessagesRead]) - const maybeAutoMarkRead = useCallback(async () => { - if (autoReadInFlightRef.current) return - if (!hasSession || unreadCount <= 0) return - if (isMinimizedRef.current || !isAtBottomRef.current) return - if (lastAutoReadCountRef.current === unreadCount) return + setUnreadCount(0) + }, [ensureConfig, messages, ticketId, unreadCount]) - autoReadInFlightRef.current = true - try { - const didMark = await markUnreadMessagesRead() - if (didMark) { - lastAutoReadCountRef.current = unreadCount - } - } finally { - autoReadInFlightRef.current = false - } - }, [hasSession, unreadCount, markUnreadMessagesRead]) - - // Auto-scroll quando novas mensagens chegam (se ja estava no bottom) - const prevMessagesLengthRef = useRef(messages.length) + // Carregar mensagens na montagem / troca de ticket useEffect(() => { - if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) { - pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true } - } - prevMessagesLengthRef.current = messages.length - }, [messages.length]) + setIsLoading(true) + setMessages([]) + setUnreadCount(0) + loadMessages() + }, [loadMessages]) - // Executar scroll pendente + // 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() + } + }) + .then((u) => { + unlisten = u + }) + .catch((err) => console.error("Falha ao registrar listener new-message:", err)) + + return () => { + unlisten?.() + } + }, [ticketId, loadMessages]) + + // Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline") + 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?.() + } + }, [ticketId, loadMessages]) + + // 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) useEffect(() => { if (isMinimized) return @@ -450,24 +517,84 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage]) + // Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente) useEffect(() => { - if (unreadCount === 0) { - lastAutoReadCountRef.current = null - return - } - maybeAutoMarkRead().catch((err) => console.error("Falha ao auto-marcar mensagens:", err)) - }, [isMinimized, isAtBottom, unreadCount, maybeAutoMarkRead]) + 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)) - // Sincronizar estado minimizado com tamanho da janela + return () => { + unlisten?.() + } + }, [ticketId, loadMessages]) + + // 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 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 + const STABILIZATION_DELAY = 500 // ms para a janela estabilizar 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) @@ -489,17 +616,16 @@ 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) - if (!apiBaseUrl || !machineToken) { - throw new Error("Configuracao nao disponivel") - } + const config = await getMachineStoreConfig() const attachment = await invoke("upload_chat_file", { - baseUrl: apiBaseUrl, - token: machineToken, + baseUrl: config.apiBaseUrl, + token: config.token, filePath, }) @@ -528,19 +654,34 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsSending(true) try { - await postMessage({ - ticketId: ticketId as Id<"tickets">, - body: messageText, - attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({ - storageId: a.storageId as Id<"_storage">, + 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, 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 { @@ -566,16 +707,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { setIsMinimized(false) try { - await invoke("open_chat_window", { ticketId, ticketRef: ticketRef ?? 0 }) + await invoke("set_chat_minimized", { ticketId, minimized: false }) } catch (err) { console.error("Erro ao expandir janela:", err) } } const handleClose = () => { - invoke("close_chat_window", { ticketId }).catch((err) => { - console.error("Erro ao fechar janela de chat:", err) - }) + invoke("close_chat_window", { ticketId }) } const handleKeyDown = (e: React.KeyboardEvent) => { @@ -585,8 +724,9 @@ 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 (
@@ -597,21 +737,38 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - // Sem sessao ativa + 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 if (!hasSession) { return (
- {ticketRef ? `Ticket #${ticketRef}` : "Chat"} + {ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"} Offline @@ -620,7 +777,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - // Minimizado + // Versão minimizada (chip compacto igual web) + // pointer-events-none no container para que apenas o botao seja clicavel if (isMinimized) { return (
@@ -630,10 +788,11 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { > - Ticket #{ticketRef} + Ticket #{ticketRef ?? ticketInfo?.ref} + {/* Badge de mensagens não lidas */} {unreadCount > 0 && ( {unreadCount > 9 ? "9+" : unreadCount} @@ -644,10 +803,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { ) } - // Expandido return ( -
- {/* Header */} +
+ {/* Header - arrastavel */}
-

- Ticket #{ticketRef} - Suporte -

+ {(ticketRef || ticketInfo) && ( +

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

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

- O agente iniciara a conversa em breve + O agente iniciará 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 = @@ -733,66 +881,61 @@ 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"}`} + className={`flex size-7 shrink-0 items-center justify-center rounded-full ${ + isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600" + }`} > - {/* Avatar */} -
- {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)} +

+
+
) })} @@ -835,7 +978,6 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { @@ -856,7 +998,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" - aria-label="Anexar arquivo" + title="Anexar arquivo" > {isUploading ? ( @@ -868,7 +1010,6 @@ 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 deleted file mode 100644 index 6793d58..0000000 --- a/apps/desktop/src/chat/ConvexMachineProvider.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * ConvexMachineProvider - Provider Convex para autenticacao via token de maquina - * - * Este provider inicializa o ConvexReactClient usando o token da maquina - * armazenado no Tauri Store, permitindo subscriptions reativas em tempo real. - * - * Arquitetura: - * - Carrega o token do Tauri Store na montagem - * - Inicializa o ConvexReactClient com a URL do Convex - * - Disponibiliza o cliente para componentes filhos via Context - * - Reconecta automaticamente quando o token muda - */ - -import { createContext, useContext, useEffect, useState, type ReactNode } from "react" -import { ConvexReactClient } from "convex/react" -import { getMachineStoreConfig } from "./machineStore" - -// URL do Convex - em producao, usa o dominio personalizado -const CONVEX_URL = import.meta.env.MODE === "production" - ? "https://convex.esdrasrenan.com.br" - : (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br") - -type MachineAuthState = { - token: string | null - apiBaseUrl: string | null - isLoading: boolean - error: string | null -} - -type ConvexMachineContextValue = { - client: ConvexReactClient | null - machineToken: string | null - apiBaseUrl: string | null - isReady: boolean - error: string | null - reload: () => Promise -} - -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 db123c0..02e7f13 100644 --- a/apps/desktop/src/chat/index.tsx +++ b/apps/desktop/src/chat/index.tsx @@ -1,65 +1,21 @@ -import { ConvexProvider } from "convex/react" import { ChatWidget } from "./ChatWidget" -import { ChatHubWidget } from "./ChatHubWidget" -import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider" -import { Loader2 } from "lucide-react" - -function ChatAppContent() { - const { client, isReady, error } = useConvexMachine() +export function ChatApp() { // Obter ticketId e ticketRef da URL const params = new URLSearchParams(window.location.search) const ticketId = params.get("ticketId") const ticketRef = params.get("ticketRef") - const isHub = params.get("hub") === "true" - - // Aguardar cliente Convex estar pronto - if (!isReady || !client) { - if (error) { - return ( -
-
- Erro: {error} -
-
- ) - } + if (!ticketId) { return ( -
-
- - Conectando... -
+
+

Erro: ticketId não fornecido

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

Dispositivo desativado

+

Dispositivo desativada

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

+ {companyName ? ( + + {companyName} + + ) : null}
@@ -42,25 +29,12 @@ export function DeactivationScreen({ onRetry }: DeactivationScreenProps) {
-
- - Falar com o suporte - - {onRetry && ( - - )} -
+ + Falar com o suporte +
diff --git a/apps/desktop/src/components/MachineStateMonitor.tsx b/apps/desktop/src/components/MachineStateMonitor.tsx deleted file mode 100644 index 69b8b80..0000000 --- a/apps/desktop/src/components/MachineStateMonitor.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * MachineStateMonitor - Componente para monitorar o estado da máquina em tempo real - * - * Este componente usa uma subscription Convex para detectar mudanças no estado da máquina: - * - Quando isActive muda para false: máquina foi desativada - * - Quando hasValidToken muda para false: máquina foi resetada (tokens revogados) - * - * O componente não renderiza nada, apenas monitora e chama callbacks quando detecta mudanças. - */ - -import { useEffect, useRef } from "react" -import { useQuery, ConvexProvider } from "convex/react" -import type { ConvexReactClient } from "convex/react" -import { api } from "../convex/_generated/api" -import type { Id } from "../convex/_generated/dataModel" - -type MachineStateMonitorProps = { - machineId: string - onDeactivated?: () => void - onTokenRevoked?: () => void - onReactivated?: () => void -} - -function MachineStateMonitorInner({ machineId, onDeactivated, onTokenRevoked, onReactivated }: MachineStateMonitorProps) { - const machineState = useQuery(api.machines.getMachineState, { - machineId: machineId as Id<"machines">, - }) - - // Refs para rastrear o estado anterior e evitar chamadas duplicadas - const previousIsActive = useRef(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 deleted file mode 100644 index 75fbfcb..0000000 --- a/apps/desktop/src/convex/_generated/api.d.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as alerts from "../alerts.js"; -import type * as automations from "../automations.js"; -import type * as bootstrap from "../bootstrap.js"; -import type * as categories from "../categories.js"; -import type * as categorySlas from "../categorySlas.js"; -import type * as checklistTemplates from "../checklistTemplates.js"; -import type * as commentTemplates from "../commentTemplates.js"; -import type * as companies from "../companies.js"; -import type * as crons from "../crons.js"; -import type * as dashboards from "../dashboards.js"; -import type * as deviceExportTemplates from "../deviceExportTemplates.js"; -import type * as deviceFieldDefaults from "../deviceFieldDefaults.js"; -import type * as deviceFields from "../deviceFields.js"; -import type * as devices from "../devices.js"; -import type * as emprestimos from "../emprestimos.js"; -import type * as fields from "../fields.js"; -import type * as files from "../files.js"; -import type * as incidents from "../incidents.js"; -import type * as invites from "../invites.js"; -import type * as liveChat from "../liveChat.js"; -import type * as machines from "../machines.js"; -import type * as metrics from "../metrics.js"; -import type * as migrations from "../migrations.js"; -import type * as ops from "../ops.js"; -import type * as queues from "../queues.js"; -import type * as rbac from "../rbac.js"; -import type * as reports from "../reports.js"; -import type * as revision from "../revision.js"; -import type * as seed from "../seed.js"; -import type * as slas from "../slas.js"; -import type * as teams from "../teams.js"; -import type * as ticketFormSettings from "../ticketFormSettings.js"; -import type * as ticketFormTemplates from "../ticketFormTemplates.js"; -import type * as ticketNotifications from "../ticketNotifications.js"; -import type * as tickets from "../tickets.js"; -import type * as usbPolicy from "../usbPolicy.js"; -import type * as users from "../users.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -declare const fullApi: ApiFromModules<{ - alerts: typeof alerts; - automations: typeof automations; - bootstrap: typeof bootstrap; - categories: typeof categories; - categorySlas: typeof categorySlas; - checklistTemplates: typeof checklistTemplates; - commentTemplates: typeof commentTemplates; - companies: typeof companies; - crons: typeof crons; - dashboards: typeof dashboards; - deviceExportTemplates: typeof deviceExportTemplates; - deviceFieldDefaults: typeof deviceFieldDefaults; - deviceFields: typeof deviceFields; - devices: typeof devices; - emprestimos: typeof emprestimos; - fields: typeof fields; - files: typeof files; - incidents: typeof incidents; - invites: typeof invites; - liveChat: typeof liveChat; - machines: typeof machines; - metrics: typeof metrics; - migrations: typeof migrations; - ops: typeof ops; - queues: typeof queues; - rbac: typeof rbac; - reports: typeof reports; - revision: typeof revision; - seed: typeof seed; - slas: typeof slas; - teams: typeof teams; - ticketFormSettings: typeof ticketFormSettings; - ticketFormTemplates: typeof ticketFormTemplates; - ticketNotifications: typeof ticketNotifications; - tickets: typeof tickets; - usbPolicy: typeof usbPolicy; - users: typeof users; -}>; - -/** - * A utility for referencing Convex functions in your app's public API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export declare const api: FilterApi< - typeof fullApi, - FunctionReference ->; - -/** - * 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 deleted file mode 100644 index 44bf985..0000000 --- a/apps/desktop/src/convex/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/apps/desktop/src/convex/_generated/dataModel.d.ts b/apps/desktop/src/convex/_generated/dataModel.d.ts deleted file mode 100644 index 8541f31..0000000 --- a/apps/desktop/src/convex/_generated/dataModel.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable */ -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, -} from "convex/server"; -import type { GenericId } from "convex/values"; -import schema from "../schema.js"; - -/** - * The names of all of your Convex tables. - */ -export type TableNames = TableNamesInDataModel; - -/** - * 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 deleted file mode 100644 index bec05e6..0000000 --- a/apps/desktop/src/convex/_generated/server.d.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, -} from "convex/server"; -import type { DataModel } from "./dataModel.js"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const query: QueryBuilder; - -/** - * 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 deleted file mode 100644 index bf3d25a..0000000 --- a/apps/desktop/src/convex/_generated/server.js +++ /dev/null @@ -1,93 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export const internalAction = internalActionGeneric; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export const httpAction = httpActionGeneric; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 3004e1c..fe95677 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -6,21 +6,12 @@ import { listen } from "@tauri-apps/api/event" import { Store } from "@tauri-apps/plugin-store" import { appLocalDataDir, join } from "@tauri-apps/api/path" import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react" -import { ConvexReactClient } from "convex/react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { cn } from "./lib/utils" import { ChatApp } from "./chat" import { DeactivationScreen } from "./components/DeactivationScreen" -import { MachineStateMonitor } from "./components/MachineStateMonitor" -import { api } from "./convex/_generated/api" -import type { Id } from "./convex/_generated/dataModel" import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types" -// URL do Convex para subscription em tempo real -const CONVEX_URL = import.meta.env.MODE === "production" - ? "https://convex.esdrasrenan.com.br" - : (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br") - type MachineOs = { name: string version?: string | null @@ -313,7 +304,7 @@ function App() { const [token, setToken] = useState(null) const [config, setConfig] = useState(null) const [profile, setProfile] = useState(null) - const [logoSrc, setLogoSrc] = useState("/logo-raven.png") + const [logoSrc, setLogoSrc] = useState(() => `${appUrl}/logo-raven.png`) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [status, setStatus] = useState(null) @@ -330,9 +321,6 @@ 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("") @@ -422,15 +410,8 @@ function App() { status: "online", intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300, }) - // Iniciar sistema de chat apos o agente - await invoke("start_chat_polling", { - baseUrl: apiBaseUrl, - convexUrl: "https://convex.esdrasrenan.com.br", - token: data.machineToken, - }) - logDesktop("chat:started") } catch (err) { - console.error("Falha ao reiniciar heartbeat/chat", err) + console.error("Falha ao reiniciar heartbeat", err) } return nextConfig @@ -605,15 +586,8 @@ function App() { status: "online", intervalSeconds: 300, }) - // Iniciar sistema de chat apos o agente - await invoke("start_chat_polling", { - baseUrl: apiBaseUrl, - convexUrl: "https://convex.esdrasrenan.com.br", - token, - }) - logDesktop("chat:started:validation") } catch (err) { - console.error("Falha ao iniciar heartbeat/chat em segundo plano", err) + console.error("Falha ao iniciar heartbeat em segundo plano", err) } const payload = await res.clone().json().catch(() => null) if (payload && typeof payload === "object" && "machine" in payload) { @@ -705,88 +679,6 @@ useEffect(() => { rustdeskInfoRef.current = rustdeskInfo }, [rustdeskInfo]) -// Cria/destrói cliente Convex quando o token muda -useEffect(() => { - if (!token) { - if (convexClient) { - convexClient.close() - setConvexClient(null) - } - return - } - - // Cria novo cliente Convex para monitoramento em tempo real - const client = new ConvexReactClient(CONVEX_URL, { - unsavedChangesWarning: false, - }) - setConvexClient(client) - - return () => { - client.close() - } -}, [token]) // eslint-disable-line react-hooks/exhaustive-deps - -// Callbacks para quando a máquina for desativada, resetada ou reativada -const handleMachineDeactivated = useCallback(() => { - console.log("[App] Máquina foi desativada - mostrando tela de bloqueio") - setIsMachineActive(false) -}, []) - -const handleMachineReactivated = useCallback(() => { - console.log("[App] Máquina foi reativada - liberando acesso") - setIsMachineActive(true) -}, []) - -// Callback para o botão "Verificar novamente" na tela de desativação -// Usa o convexClient diretamente para fazer uma query manual -const handleRetryCheck = useCallback(async () => { - if (!convexClient || !config?.machineId) return - console.log("[App] Verificando estado da máquina manualmente...") - try { - const state = await convexClient.query(api.machines.getMachineState, { - machineId: config.machineId as Id<"machines">, - }) - console.log("[App] Estado da máquina:", state) - if (state?.isActive) { - console.log("[App] Máquina ativa - liberando acesso") - setIsMachineActive(true) - } - } catch (err) { - console.error("[App] Erro ao verificar estado:", err) - } -}, [convexClient, config?.machineId]) - -const handleTokenRevoked = useCallback(async () => { - console.log("[App] Token foi revogado - voltando para tela de registro") - if (store) { - try { - await store.delete("token") - await store.delete("config") - await store.save() - } catch (err) { - console.error("Falha ao limpar store", err) - } - } - tokenVerifiedRef.current = false - autoLaunchRef.current = false - setToken(null) - setConfig(null) - setStatus(null) - setIsMachineActive(true) - setIsLaunchingSystem(false) - // Limpa campos de input para novo registro - setProvisioningCode("") - setCollabEmail("") - setCollabName("") - setValidatedCompany(null) - setCodeStatus(null) - setCompanyName("") - setError("Este dispositivo foi resetado. Informe o código de provisionamento para reconectar.") - // Força navegar de volta para a página inicial do app Tauri (não do servidor web) - // URL do app Tauri em produção é http://tauri.localhost/, em dev é http://localhost:1420/ - const appUrl = import.meta.env.MODE === "production" ? "http://tauri.localhost/" : "http://localhost:1420/" - window.location.href = appUrl -}, [store]) useEffect(() => { if (!store || !config) return @@ -1357,10 +1249,6 @@ const resolvedAppUrl = useMemo(() => { const openSystem = useCallback(async () => { if (!token) return - if (!isMachineActive) { - setIsLaunchingSystem(false) - return - } setIsLaunchingSystem(true) // Recarrega store do disco para pegar dados que o Rust salvou diretamente @@ -1420,6 +1308,7 @@ const resolvedAppUrl = useMemo(() => { setError(null) } if (!currentActive) { + setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.") setIsLaunchingSystem(false) return } @@ -1427,8 +1316,14 @@ const resolvedAppUrl = useMemo(() => { } } else { if (res.status === 423) { + const payload = await res.clone().json().catch(() => null) + const message = + payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string" + ? ((payload as { error?: string }).error ?? "").trim() + : "" setIsMachineActive(false) setIsLaunchingSystem(false) + setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.") return } // Se sessão falhar, tenta identificar token inválido/expirado @@ -1478,7 +1373,7 @@ const resolvedAppUrl = useMemo(() => { const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") }) window.location.href = url - }, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive]) + }, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store]) async function reprovision() { if (!store) return @@ -1583,28 +1478,17 @@ const resolvedAppUrl = useMemo(() => { if (!token) return if (autoLaunchRef.current) return if (!tokenVerifiedRef.current) return - if (!isMachineActive) return // Não redireciona se a máquina estiver desativada autoLaunchRef.current = true setIsLaunchingSystem(true) openSystem() - }, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive]) + }, [token, status, config?.accessRole, openSystem, tokenValidationTick]) // Quando há token persistido (dispositivo já provisionado) e ainda não // disparamos o auto-launch, exibimos diretamente a tela de loading da // plataforma para evitar piscar o card de resumo/inventário. - // IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real - if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) { + if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) { return (
- {/* Monitor de estado da máquina - deve rodar mesmo durante loading */} - {token && config?.machineId && convexClient && ( - - )}

Abrindo plataforma da Rever…

@@ -1614,31 +1498,11 @@ 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 (
- {/* Monitor de estado da maquina em tempo real via Convex */} - {machineMonitor} + {token && !isMachineActive ? ( + + ) : (
{ alt="Logotipo Raven" width={160} height={160} - className="h-16 w-auto md:h-20" + className="h-14 w-auto md:h-16" onError={() => { if (logoFallbackRef.current) return logoFallbackRef.current = true - setLogoSrc(`${appUrl}/logo-raven.png`) + setLogoSrc(`${appUrl}/raven.png`) }} />
- Raven -
- - Plataforma de - - - Chamados - -
+ Raven + Sistema de chamados
@@ -1866,6 +1723,8 @@ const resolvedAppUrl = useMemo(() => {
)}
+ )} +
) } diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 5e0a227..0a3d9c7 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -19,13 +19,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "jsx": "react-jsx", - "types": ["vite/client"], - - /* Paths */ - "baseUrl": ".", - "paths": { - "@convex/_generated/*": ["./src/convex/_generated/*"] - } + "types": ["vite/client"] }, "include": ["src"] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 1f22f44..9c1d6d2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import { resolve } from "path"; const host = process.env.TAURI_DEV_HOST; @@ -8,13 +7,6 @@ const host = process.env.TAURI_DEV_HOST; export default defineConfig(async () => ({ plugins: [react()], - resolve: { - alias: { - // Usar arquivos _generated locais para evitar problemas de type-check - "@convex/_generated": resolve(__dirname, "./src/convex/_generated"), - }, - }, - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors diff --git a/bun.lock b/bun.lock index 4eddfda..013a138 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.7", - "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -115,7 +114,6 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2", - "convex": "^1.31.0", "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -514,8 +512,6 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], @@ -2336,8 +2332,6 @@ "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="], - "appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], "appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], @@ -2472,8 +2466,6 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], - "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -2605,55 +2597,5 @@ "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], - - "appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], - - "appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], - - "appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], - - "appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], - - "appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], - - "appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], - - "appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], - - "appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], - - "appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], - - "appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], - - "appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], - - "appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], - - "appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], - - "appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], - - "appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], - - "appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], } } diff --git a/convex/automations.ts b/convex/automations.ts index 59216b2..d6abc56 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 type { AutomationEmailProps } from "./reactEmail" +import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail" import { buildBaseUrl } from "./url" import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist" @@ -988,38 +988,19 @@ async function applyActions( ctaLabel, ctaUrl, } + const html = await renderAutomationEmailHtml(emailProps) await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, { to, subject, - emailProps: { - title: emailProps.title, - message: emailProps.message, - ticket: { - reference: emailProps.ticket.reference, - subject: emailProps.ticket.subject, - status: emailProps.ticket.status ?? null, - priority: emailProps.ticket.priority ?? null, - companyName: emailProps.ticket.companyName ?? null, - requesterName: emailProps.ticket.requesterName ?? null, - assigneeName: emailProps.ticket.assigneeName ?? null, - }, - ctaLabel: emailProps.ctaLabel, - ctaUrl: emailProps.ctaUrl, - }, + html, }) applied.push({ type: "SEND_EMAIL", details: { - recipients: to, toCount: to.length, - subject, - messagePreview: message.length > 100 ? `${message.slice(0, 100)}...` : message, ctaTarget: effectiveTarget, - ctaLabel, - ctaUrl, - scheduledAt: Date.now(), }, }) } diff --git a/convex/checklistTemplates.ts b/convex/checklistTemplates.ts index c9329b5..c9c410a 100644 --- a/convex/checklistTemplates.ts +++ b/convex/checklistTemplates.ts @@ -14,37 +14,17 @@ function normalizeTemplateDescription(input: string | null | undefined) { return text.length > 0 ? text : null } -type ChecklistItemType = "checkbox" | "question" - -type RawTemplateItem = { - id?: string - text: string - description?: string - type?: string - options?: string[] - required?: boolean -} - -type NormalizedTemplateItem = { - id: string - text: string - description?: string - type?: ChecklistItemType - options?: string[] - required?: boolean -} - function normalizeTemplateItems( - raw: RawTemplateItem[], + raw: Array<{ id?: string; text: string; required?: boolean }>, options: { generateId?: () => string } -): NormalizedTemplateItem[] { +) { if (!Array.isArray(raw) || raw.length === 0) { throw new ConvexError("Adicione pelo menos um item no checklist.") } const generateId = options.generateId ?? (() => crypto.randomUUID()) const seen = new Set() - const items: NormalizedTemplateItem[] = [] + const items: Array<{ id: string; text: string; required?: boolean }> = [] for (const entry of raw) { const id = String(entry.id ?? "").trim() || generateId() @@ -61,25 +41,8 @@ function normalizeTemplateItems( throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).") } - const description = entry.description?.trim() || undefined - const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox" - const itemOptions = itemType === "question" && Array.isArray(entry.options) - ? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0) - : undefined - - if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) { - throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opções.`) - } - const required = typeof entry.required === "boolean" ? entry.required : true - items.push({ - id, - text, - description, - type: itemType, - options: itemOptions, - required, - }) + items.push({ id, text, required }) } return items @@ -94,9 +57,6 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co items: (template.items ?? []).map((item) => ({ id: item.id, text: item.text, - description: item.description, - type: item.type ?? "checkbox", - options: item.options, required: typeof item.required === "boolean" ? item.required : true, })), isArchived: Boolean(template.isArchived), @@ -204,9 +164,6 @@ export const create = mutation({ v.object({ id: v.optional(v.string()), text: v.string(), - description: v.optional(v.string()), - type: v.optional(v.string()), - options: v.optional(v.array(v.string())), required: v.optional(v.boolean()), }), ), @@ -259,9 +216,6 @@ export const update = mutation({ v.object({ id: v.optional(v.string()), text: v.string(), - description: v.optional(v.string()), - type: v.optional(v.string()), - options: v.optional(v.array(v.string())), required: v.optional(v.boolean()), }), ), @@ -305,72 +259,3 @@ export const update = mutation({ return { ok: true } }, }) - -export const remove = mutation({ - args: { - tenantId: v.string(), - actorId: v.id("users"), - templateId: v.id("ticketChecklistTemplates"), - }, - handler: async (ctx, { tenantId, actorId, templateId }) => { - await requireAdmin(ctx, actorId, tenantId) - - const existing = await ctx.db.get(templateId) - if (!existing || existing.tenantId !== tenantId) { - throw new ConvexError("Template de checklist não encontrado.") - } - - await ctx.db.delete(templateId) - - 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 deleted file mode 100644 index f7412fe..0000000 --- a/convex/companySlas.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { mutation, query } from "./_generated/server" -import { ConvexError, v } from "convex/values" -import type { Id } from "./_generated/dataModel" - -import { requireAdmin } from "./rbac" - -const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const -const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const -const VALID_TIME_MODES = ["business", "calendar"] as const - -type CompanySlaRuleInput = { - priority: string - categoryId?: string | null - responseTargetMinutes?: number | null - responseMode?: string | null - solutionTargetMinutes?: number | null - solutionMode?: string | null - alertThreshold?: number | null - pauseStatuses?: string[] | null - calendarType?: string | null -} - -const ruleInput = v.object({ - priority: v.string(), - categoryId: v.optional(v.union(v.id("ticketCategories"), v.null())), - responseTargetMinutes: v.optional(v.number()), - responseMode: v.optional(v.string()), - solutionTargetMinutes: v.optional(v.number()), - solutionMode: v.optional(v.string()), - alertThreshold: v.optional(v.number()), - pauseStatuses: v.optional(v.array(v.string())), - calendarType: v.optional(v.string()), -}) - -function normalizePriority(value: string) { - const upper = value.trim().toUpperCase() - return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT" -} - -function sanitizeTime(value?: number | null) { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined - return Math.round(value) -} - -function normalizeMode(value?: string | null) { - if (!value) return "calendar" - const normalized = value.toLowerCase() - return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar" -} - -function normalizeThreshold(value?: number | null) { - if (typeof value !== "number" || Number.isNaN(value)) { - return 0.8 - } - const clamped = Math.min(Math.max(value, 0.1), 0.95) - return Math.round(clamped * 100) / 100 -} - -function normalizePauseStatuses(value?: string[] | null) { - if (!Array.isArray(value)) return ["PAUSED"] - const normalized = new Set() - 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 0c700ae..94109ef 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -168,40 +168,7 @@ export const startSession = mutation({ createdAt: now, }) - // Iniciar timer automaticamente se nao houver sessao de trabalho ativa - // O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente) - let workSessionId: Id<"ticketWorkSessions"> | null = null - if (!ticket.activeSessionId && ticket.assigneeId) { - workSessionId = await ctx.db.insert("ticketWorkSessions", { - ticketId, - agentId: ticket.assigneeId, - workType: "EXTERNAL", - startedAt: now, - }) - - await ctx.db.patch(ticketId, { - working: true, - activeSessionId: workSessionId, - status: "AWAITING_ATTENDANCE", - updatedAt: now, - }) - - await ctx.db.insert("ticketEvents", { - ticketId, - type: "WORK_STARTED", - payload: { - actorId, - actorName: agent.name, - actorAvatar: agent.avatarUrl, - sessionId: workSessionId, - workType: "EXTERNAL", - source: "live_chat_auto", - }, - createdAt: now, - }) - } - - return { sessionId, isNew: true, workSessionStarted: workSessionId !== null } + return { sessionId, isNew: true } }, }) @@ -258,60 +225,7 @@ export const endSession = mutation({ createdAt: now, }) - // Pausar timer automaticamente se houver sessao de trabalho ativa - let workSessionPaused = false - const ticket = await ctx.db.get(session.ticketId) - if (ticket?.activeSessionId) { - const workSession = await ctx.db.get(ticket.activeSessionId) - if (workSession && !workSession.stoppedAt) { - const workDurationMs = now - workSession.startedAt - const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase() - const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0 - const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0 - - // Encerrar sessao de trabalho - await ctx.db.patch(ticket.activeSessionId, { - stoppedAt: now, - durationMs: workDurationMs, - pauseReason: "END_LIVE_CHAT", - pauseNote: "Pausa automática ao encerrar chat ao vivo", - }) - - // Atualizar ticket - await ctx.db.patch(session.ticketId, { - working: false, - activeSessionId: undefined, - status: "PAUSED", - totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs, - internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, - externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, - updatedAt: now, - }) - - // Registrar evento de pausa - await ctx.db.insert("ticketEvents", { - ticketId: session.ticketId, - type: "WORK_PAUSED", - payload: { - actorId, - actorName: actor.name, - actorAvatar: actor.avatarUrl, - sessionId: workSession._id, - sessionDurationMs: workDurationMs, - workType: sessionType, - pauseReason: "END_LIVE_CHAT", - pauseReasonLabel: "Chat ao vivo encerrado", - pauseNote: "Pausa automática ao encerrar chat ao vivo", - source: "live_chat_auto", - }, - createdAt: now, - }) - - workSessionPaused = true - } - } - - return { ok: true, workSessionPaused } + return { ok: true } }, }) @@ -503,14 +417,8 @@ export const listMachineSessions = query({ // Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak) .take(50) - // Filtrar apenas sessão problemática legada (ID hardcoded) - // Nota: lastAgentMessageAt pode ser undefined em sessões novas onde o agente ainda não enviou mensagem - const validSessions = sessions.filter( - (s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" - ) - const result = await Promise.all( - validSessions.map(async (session) => { + sessions.map(async (session) => { const ticket = await ctx.db.get(session.ticketId) return { sessionId: session._id, @@ -612,18 +520,13 @@ export const checkMachineUpdates = query({ const { machine } = await validateMachineToken(ctx, args.machineToken) // Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak) - const rawSessions = await ctx.db + const sessions = await ctx.db .query("liveChatSessions") .withIndex("by_machine_status", (q) => q.eq("machineId", machine._id).eq("status", "ACTIVE") ) .take(50) - // Filtrar sessões problemáticas (sem campos obrigatórios) - const sessions = rawSessions.filter( - (s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined - ) - if (sessions.length === 0) { return { hasActiveSessions: false, @@ -860,40 +763,27 @@ export const getTicketChatHistory = query({ // Timeout de maquina offline: 5 minutos sem heartbeat const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 -// Timeout de inatividade do chat: 12 horas sem atividade -// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar -const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000 - -// Mutation interna para encerrar sessões inativas (chamada pelo cron) -// Critérios de encerramento: -// 1. Máquina offline (5 min sem heartbeat) -// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online -// 3. Ticket órfão (sem máquina vinculada) +// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron) +// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat +// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { - console.log("cron: autoEndInactiveSessions iniciado") + console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") const now = Date.now() const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS - const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS // Limitar a 50 sessões por execução para evitar timeout do cron (30s) const maxSessionsPerRun = 50 // Buscar todas as sessões ativas - const rawActiveSessions = await ctx.db + const activeSessions = await ctx.db .query("liveChatSessions") .withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE")) .take(maxSessionsPerRun) - // Filtrar sessões problemáticas (sem campos obrigatórios) - const activeSessions = rawActiveSessions.filter( - (s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined - ) - let endedCount = 0 let checkedCount = 0 - const reasons: Record = {} for (const session of activeSessions) { checkedCount++ @@ -922,36 +812,6 @@ export const autoEndInactiveSessions = mutation({ createdAt: now, }) endedCount++ - reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1 - continue - } - - // Verificar inatividade do chat (12 horas sem atividade) - // Isso tem prioridade sobre o status da máquina - const chatIsInactive = session.lastActivityAt < inactivityCutoff - if (chatIsInactive) { - await ctx.db.patch(session._id, { - status: "ENDED", - endedAt: now, - }) - await ctx.db.insert("ticketEvents", { - ticketId: session.ticketId, - type: "LIVE_CHAT_ENDED", - payload: { - sessionId: session._id, - agentId: session.agentId, - agentName: session.agentSnapshot?.name ?? "Sistema", - durationMs: now - session.startedAt, - startedAt: session.startedAt, - endedAt: now, - autoEnded: true, - reason: "inatividade_chat", - inactiveForMs: now - session.lastActivityAt, - }, - createdAt: now, - }) - endedCount++ - reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1 continue } @@ -959,7 +819,7 @@ export const autoEndInactiveSessions = mutation({ const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff - // Se máquina está online e chat está ativo, manter sessão + // Se máquina está online, manter sessão ativa if (machineIsOnline) { continue } @@ -989,40 +849,10 @@ export const autoEndInactiveSessions = mutation({ }) endedCount++ - reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1 } - const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ") - console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`) - return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun } - }, -}) - -// Mutation para corrigir sessoes antigas sem campos obrigatorios -export const fixLegacySessions = mutation({ - args: {}, - handler: async (ctx) => { - // IDs problematicos conhecidos - sessoes sem lastAgentMessageAt - const knownProblematicIds = [ - "pd71bvfbxx7th3npdj519hcf3s7xbe2j", - ] - - let deleted = 0 - const results: string[] = [] - - for (const sessionId of knownProblematicIds) { - try { - // Deletar a sessao problematica diretamente (evita erro de shape ao ler) - await ctx.db.delete(sessionId as Id<"liveChatSessions">) - deleted++ - results.push(`${sessionId}: deleted`) - } catch (error) { - results.push(`${sessionId}: error - ${error}`) - } - } - - console.log(`fixLegacySessions: deleted=${deleted}, results=${results.join(", ")}`) - return { deleted, results } + console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) + return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } }, }) diff --git a/convex/machineSoftware.ts b/convex/machineSoftware.ts deleted file mode 100644 index 1615391..0000000 --- a/convex/machineSoftware.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { mutation, query, internalMutation } from "./_generated/server" -import { v } from "convex/values" -import type { Id } from "./_generated/dataModel" - -// Tipo para software recebido do agente -type SoftwareInput = { - name: string - version?: string - publisher?: string - source?: string -} - -// Upsert de softwares de uma maquina (chamado pelo heartbeat) -export const syncFromHeartbeat = internalMutation({ - args: { - tenantId: v.string(), - machineId: v.id("machines"), - software: v.array( - v.object({ - name: v.string(), - version: v.optional(v.string()), - publisher: v.optional(v.string()), - source: v.optional(v.string()), - }) - ), - }, - handler: async (ctx, { tenantId, machineId, software }) => { - const now = Date.now() - - // Busca softwares existentes da maquina - const existing = await ctx.db - .query("machineSoftware") - .withIndex("by_machine", (q) => q.eq("machineId", machineId)) - .collect() - - const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s])) - - // Processa cada software recebido - const seenKeys = new Set() - 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 8a688a6..3428759 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 { internal, api } from "./_generated/api" +import { api } from "./_generated/api" import { paginationOptsValidator } from "convex/server" import { ConvexError, v, Infer } from "convex/values" import { sha256 } from "@noble/hashes/sha2.js" @@ -331,59 +331,9 @@ async function getMachineLastHeartbeat( return hb?.lastHeartbeatAt ?? fallback ?? null } -// Campo software é muito grande e é tratado separadamente via machineSoftware - -// Extrai campos importantes do extended antes de bloqueá-lo -function extractFromExtended(extended: unknown): JsonRecord { - const result: JsonRecord = {} - const sanitizedExtended = sanitizeRecord(extended) - if (!sanitizedExtended) return result - - // Extrair dados do Windows - const windows = sanitizeRecord(sanitizedExtended["windows"]) - if (windows) { - const windowsFields: JsonRecord = {} - // bootInfo - informacoes de reinicio - if (windows["bootInfo"]) { - windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue - } - // osInfo - informacoes do sistema operacional - if (windows["osInfo"]) { - windowsFields["osInfo"] = windows["osInfo"] as JsonValue - } - // cpu, baseboard, bios, memoryModules, videoControllers, disks - for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) { - if (windows[key]) { - windowsFields[key] = windows[key] as JsonValue - } - } - if (Object.keys(windowsFields).length > 0) { - result["windows"] = windowsFields - } - } - - // Extrair dados do Linux - const linux = sanitizeRecord(sanitizedExtended["linux"]) - if (linux) { - const linuxFields: JsonRecord = {} - for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) { - if (linux[key]) { - linuxFields[key] = linux[key] as JsonValue - } - } - if (Object.keys(linuxFields).length > 0) { - result["linux"] = linuxFields - } - } - - // Extrair dados do macOS - const macos = sanitizeRecord(sanitizedExtended["macos"]) - if (macos) { - result["macos"] = macos as JsonValue - } - - return result -} +// Campos do inventory que sao muito grandes e nao devem ser persistidos +// para evitar OOM no Convex (documentos de ~100KB cada) +const INVENTORY_BLOCKLIST = new Set(["software", "extended"]) function mergeInventory(current: JsonRecord | null | undefined, patch: Record): JsonRecord { const sanitizedPatch = sanitizeRecord(patch) @@ -391,10 +341,9 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record) @@ -444,20 +393,9 @@ function ensureString(value: unknown): string | null { function sanitizeInventoryPayload(value: unknown): JsonRecord | null { const record = sanitizeRecord(value) if (!record) return null - - // Extrair campos importantes do extended antes de deletá-lo - if (record["extended"]) { - const extractedExtended = extractFromExtended(record["extended"]) - if (Object.keys(extractedExtended).length > 0) { - record["extended"] = extractedExtended - } else { - delete record["extended"] - } + for (const blocked of INVENTORY_BLOCKLIST) { + delete record[blocked] } - - // Deletar apenas software (extended já foi processado acima) - delete record["software"] - return record } @@ -1018,13 +956,10 @@ export const heartbeat = mutation({ } } - // Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata) - const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record | undefined) - const sanitizedInventory = sanitizeInventoryPayload(rawInventory) + const sanitizedInventory = sanitizeInventoryPayload(args.inventory) const currentInventory = ensureRecord(currentMetadata.inventory) const incomingInventoryHash = hashJson(sanitizedInventory) const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null - if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) { metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory) metadataPatch.inventoryHash = incomingInventoryHash @@ -1075,34 +1010,6 @@ export const heartbeat = mutation({ await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) } - // Processar softwares instalados (armazenados em tabela separada) - // Os dados de software sao extraidos ANTES de sanitizar o inventory - // Usa rawInventory ja extraido anteriormente (linha ~1022) - if (rawInventory && typeof rawInventory === "object") { - const softwareArray = (rawInventory as Record)["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, @@ -2410,44 +2317,6 @@ export const resetAgent = mutation({ }, }) -/** - * Query para o desktop monitorar o estado da máquina em tempo real. - * O desktop faz subscribe nessa query e reage imediatamente quando: - * - isActive muda para false (desativação) - * - hasValidToken muda para false (reset/revogação de tokens) - */ -export const getMachineState = query({ - args: { - machineId: v.id("machines"), - }, - handler: async (ctx, { machineId }) => { - const machine = await ctx.db.get(machineId) - if (!machine) { - return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const } - } - - // Verifica se existe algum token válido (não revogado e não expirado) - const now = Date.now() - const tokens = await ctx.db - .query("machineTokens") - .withIndex("by_machine", (q) => q.eq("machineId", machineId)) - .take(10) - - const hasValidToken = tokens.some((token) => { - if (token.revoked) return false - if (token.expiresAt && token.expiresAt < now) return false - return true - }) - - return { - found: true, - isActive: machine.isActive ?? true, - hasValidToken, - status: machine.status ?? "unknown", - } - }, -}) - type RemoteAccessEntry = { id: string provider: string diff --git a/convex/migrations.ts b/convex/migrations.ts index 6127128..9ea35e6 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -1043,81 +1043,3 @@ export const backfillTicketSnapshots = mutation({ return { processed } }, }) - -/** - * Migration para remover comentarios duplicados de troca de responsavel. - * Esses comentarios eram criados automaticamente ao trocar o responsavel, - * mas essa informacao ja aparece na linha do tempo (ticketEvents). - * O comentario segue o padrao: "

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 78bb6a9..2f70228 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -154,17 +154,10 @@ export const summary = query({ const now = Date.now(); for (const ticket of tickets) { const status = normalizeStatus(ticket.status); - const isWorking = ticket.working === true; if (status === "PENDING") { pending += 1; } else if (status === "AWAITING_ATTENDANCE") { - // "Em andamento" conta apenas tickets com play ativo - if (isWorking) { - inProgress += 1; - } else { - // Tickets em atendimento sem play ativo contam como "Em aberto" - pending += 1; - } + inProgress += 1; } else if (status === "PAUSED") { paused += 1; } diff --git a/convex/reactEmail.tsx b/convex/reactEmail.tsx index 7ec9382..b81fade 100644 --- a/convex/reactEmail.tsx +++ b/convex/reactEmail.tsx @@ -3,31 +3,8 @@ import { render } from "@react-email/render" import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email" import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email" -import InviteEmail, { type InviteEmailProps } from "../emails/invite-email" -import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email" -import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email" -import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email" -import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email" -import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email" -import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email" -import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email" -import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email" -import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email" -export type { - AutomationEmailProps, - SimpleNotificationEmailProps, - InviteEmailProps, - PasswordResetEmailProps, - NewLoginEmailProps, - SlaWarningEmailProps, - SlaBreachedEmailProps, - TicketCreatedEmailProps, - TicketResolvedEmailProps, - TicketAssignedEmailProps, - TicketStatusEmailProps, - TicketCommentEmailProps, -} +export type { AutomationEmailProps, SimpleNotificationEmailProps } export async function renderAutomationEmailHtml(props: AutomationEmailProps) { return render(, { pretty: false }) @@ -36,43 +13,3 @@ 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 563a082..5c18044 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -161,8 +161,11 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks } } -function logDashboardProgress(_processed: number, _tenantId: string) { - // Log de progresso removido para reduzir ruido no console +function logDashboardProgress(processed: number, tenantId: string) { + const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024)); + console.log( + `[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`, + ); } function mapToChronologicalSeries(map: Map) { @@ -2403,20 +2406,18 @@ export const companyOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), - companyId: v.optional(v.id("companies")), + companyId: v.id("companies"), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); - const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { + throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); + } - // Buscar dados da empresa selecionada (se houver) - let company: Doc<"companies"> | null = null; - if (scopedCompanyId) { - company = await ctx.db.get(scopedCompanyId); - if (!company || company.tenantId !== tenantId) { - throw new ConvexError("Empresa não encontrada"); - } + const company = await ctx.db.get(companyId); + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada"); } const normalizedRange = (range ?? "30d").toLowerCase(); @@ -2425,35 +2426,20 @@ export const companyOverview = query({ const startMs = now - rangeDays * ONE_DAY_MS; // Limita consultas para evitar OOM em empresas muito grandes - const tickets = scopedCompanyId - ? await ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) - .take(2000) - : await ctx.db - .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .take(2000); + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(2000); - const machines = scopedCompanyId - ? await ctx.db - .query("machines") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) - .take(1000) - : await ctx.db - .query("machines") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .take(1000); + const machines = await ctx.db + .query("machines") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(1000); - const users = scopedCompanyId - ? await ctx.db - .query("users") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) - .take(500) - : await ctx.db - .query("users") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .take(500); + const users = await ctx.db + .query("users") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(500); const statusCounts = {} as Record; const priorityCounts = {} as Record; @@ -2548,13 +2534,11 @@ export const companyOverview = query({ }); return { - company: company - ? { - id: company._id, - name: company.name, - isAvulso: company.isAvulso ?? false, - } - : null, + company: { + id: company._id, + name: company.name, + isAvulso: company.isAvulso ?? false, + }, rangeDays, generatedAt: now, tickets: { diff --git a/convex/schema.ts b/convex/schema.ts index 9e3502a..3402484 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -82,7 +82,6 @@ export default defineSchema({ contacts: v.optional(v.any()), locations: v.optional(v.any()), sla: v.optional(v.any()), - reopenWindowDays: v.optional(v.number()), tags: v.optional(v.array(v.string())), customFields: v.optional(v.any()), notes: v.optional(v.string()), @@ -200,11 +199,7 @@ export default defineSchema({ name: v.string(), description: v.optional(v.string()), timeToFirstResponse: v.optional(v.number()), // minutes - responseMode: v.optional(v.string()), // "business" | "calendar" timeToResolution: v.optional(v.number()), // minutes - solutionMode: v.optional(v.string()), // "business" | "calendar" - alertThreshold: v.optional(v.number()), // 0.1 a 0.95 - pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA }).index("by_tenant_name", ["tenantId", "name"]), tickets: defineTable({ @@ -319,15 +314,10 @@ export default defineSchema({ v.object({ id: v.string(), text: v.string(), - description: v.optional(v.string()), - type: v.optional(v.string()), // "checkbox" | "question" - options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...] - answer: v.optional(v.string()), // Resposta selecionada para tipo "question" done: v.boolean(), required: v.optional(v.boolean()), templateId: v.optional(v.id("ticketChecklistTemplates")), templateItemId: v.optional(v.string()), - templateDescription: v.optional(v.string()), // Descricao do template (copiada ao aplicar) createdAt: v.optional(v.number()), createdBy: v.optional(v.id("users")), doneAt: v.optional(v.number()), @@ -488,7 +478,6 @@ export default defineSchema({ startedAt: v.number(), endedAt: v.optional(v.number()), lastActivityAt: v.number(), - lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel) unreadByMachine: v.optional(v.number()), unreadByAgent: v.optional(v.number()), }) @@ -598,29 +587,6 @@ export default defineSchema({ .index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"]) .index("by_tenant_category", ["tenantId", "categoryId"]), - // SLA por empresa - permite configurar políticas de SLA específicas por cliente - // Quando um ticket é criado, o sistema busca primeiro aqui antes de usar categorySlaSettings - companySlaSettings: defineTable({ - tenantId: v.string(), - companyId: v.id("companies"), - // Se categoryId for null, aplica-se a todas as categorias da empresa - categoryId: v.optional(v.id("ticketCategories")), - priority: v.string(), // URGENT, HIGH, MEDIUM, LOW, DEFAULT - responseTargetMinutes: v.optional(v.number()), - responseMode: v.optional(v.string()), // "business" | "calendar" - solutionTargetMinutes: v.optional(v.number()), - solutionMode: v.optional(v.string()), // "business" | "calendar" - alertThreshold: v.optional(v.number()), // 0.1 a 0.95 (ex: 0.8 = 80%) - pauseStatuses: v.optional(v.array(v.string())), - calendarType: v.optional(v.string()), - createdAt: v.number(), - updatedAt: v.number(), - actorId: v.optional(v.id("users")), - }) - .index("by_tenant_company", ["tenantId", "companyId"]) - .index("by_tenant_company_category", ["tenantId", "companyId", "categoryId"]) - .index("by_tenant_company_category_priority", ["tenantId", "companyId", "categoryId", "priority"]), - ticketFields: defineTable({ tenantId: v.string(), key: v.string(), @@ -692,9 +658,6 @@ export default defineSchema({ v.object({ id: v.string(), text: v.string(), - description: v.optional(v.string()), - type: v.optional(v.string()), // "checkbox" | "question" - options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...] required: v.optional(v.boolean()), }) ), @@ -825,25 +788,6 @@ export default defineSchema({ }) .index("by_machine", ["machineId"]), - // Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao - // Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada - machineSoftware: defineTable({ - tenantId: v.string(), - machineId: v.id("machines"), - name: v.string(), - nameLower: v.string(), // Para busca case-insensitive - version: v.optional(v.string()), - publisher: v.optional(v.string()), - source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc - installedAt: v.optional(v.number()), // Data de instalacao (se disponivel) - detectedAt: v.number(), // Quando foi detectado pelo agente - lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat - }) - .index("by_machine", ["machineId"]) - .index("by_machine_name", ["machineId", "nameLower"]) - .index("by_tenant_name", ["tenantId", "nameLower"]) - .index("by_tenant_machine", ["tenantId", "machineId"]), - machineTokens: defineTable({ tenantId: v.string(), machineId: v.id("machines"), diff --git a/convex/slas.ts b/convex/slas.ts index 27f6645..32ab0a5 100644 --- a/convex/slas.ts +++ b/convex/slas.ts @@ -9,26 +9,6 @@ function normalizeName(value: string) { return value.trim(); } -function normalizeMode(value?: string): "business" | "calendar" { - if (value === "business") return "business"; - return "calendar"; -} - -function normalizeThreshold(value?: number): number { - if (value === undefined || value === null) return 0.8; - if (value < 0.1) return 0.1; - if (value > 0.95) return 0.95; - return value; -} - -const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const; - -function normalizePauseStatuses(statuses?: string[]): string[] { - if (!statuses || statuses.length === 0) return ["PAUSED"]; - const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number])); - return filtered.length > 0 ? filtered : ["PAUSED"]; -} - type AnyCtx = QueryCtx | MutationCtx; async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) { @@ -55,11 +35,7 @@ export const list = query({ name: policy.name, description: policy.description ?? "", timeToFirstResponse: policy.timeToFirstResponse ?? null, - responseMode: policy.responseMode ?? "calendar", timeToResolution: policy.timeToResolution ?? null, - solutionMode: policy.solutionMode ?? "calendar", - alertThreshold: policy.alertThreshold ?? 0.8, - pauseStatuses: policy.pauseStatuses ?? ["PAUSED"], })); }, }); @@ -71,14 +47,9 @@ export const create = mutation({ name: v.string(), description: v.optional(v.string()), timeToFirstResponse: v.optional(v.number()), - responseMode: v.optional(v.string()), timeToResolution: v.optional(v.number()), - solutionMode: v.optional(v.string()), - alertThreshold: v.optional(v.number()), - pauseStatuses: v.optional(v.array(v.string())), }, - handler: async (ctx, args) => { - const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args; + handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { await requireAdmin(ctx, actorId, tenantId); const trimmed = normalizeName(name); if (trimmed.length < 2) { @@ -97,11 +68,7 @@ export const create = mutation({ name: trimmed, description, timeToFirstResponse, - responseMode: normalizeMode(responseMode), timeToResolution, - solutionMode: normalizeMode(solutionMode), - alertThreshold: normalizeThreshold(alertThreshold), - pauseStatuses: normalizePauseStatuses(pauseStatuses), }); return id; }, @@ -115,14 +82,9 @@ export const update = mutation({ name: v.string(), description: v.optional(v.string()), timeToFirstResponse: v.optional(v.number()), - responseMode: v.optional(v.string()), timeToResolution: v.optional(v.number()), - solutionMode: v.optional(v.string()), - alertThreshold: v.optional(v.number()), - pauseStatuses: v.optional(v.array(v.string())), }, - handler: async (ctx, args) => { - const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args; + handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { await requireAdmin(ctx, actorId, tenantId); const policy = await ctx.db.get(policyId); if (!policy || policy.tenantId !== tenantId) { @@ -144,11 +106,7 @@ export const update = mutation({ name: trimmed, description, timeToFirstResponse, - responseMode: normalizeMode(responseMode), timeToResolution, - solutionMode: normalizeMode(solutionMode), - alertThreshold: normalizeThreshold(alertThreshold), - pauseStatuses: normalizePauseStatuses(pauseStatuses), }); }, }); diff --git a/convex/ticketChecklist.ts b/convex/ticketChecklist.ts index 31bbb8d..efef60b 100644 --- a/convex/ticketChecklist.ts +++ b/convex/ticketChecklist.ts @@ -1,38 +1,21 @@ import type { Id } from "./_generated/dataModel" -export type ChecklistItemType = "checkbox" | "question" - export type TicketChecklistItem = { id: string text: string - description?: string - type?: ChecklistItemType - options?: string[] // Para tipo "question": ["Sim", "Nao", ...] - answer?: string // Resposta selecionada para tipo "question" done: boolean required?: boolean templateId?: Id<"ticketChecklistTemplates"> templateItemId?: string - templateDescription?: string // Descricao do template (copiada ao aplicar) createdAt?: number createdBy?: Id<"users"> doneAt?: number doneBy?: Id<"users"> } -export type TicketChecklistTemplateItem = { - id: string - text: string - description?: string - type?: string // "checkbox" | "question" - string para compatibilidade com schema - options?: string[] - required?: boolean -} - export type TicketChecklistTemplateLike = { _id: Id<"ticketChecklistTemplates"> - description?: string - items: TicketChecklistTemplateItem[] + items: Array<{ id: string; text: string; required?: boolean }> } export function normalizeChecklistText(input: string) { @@ -70,18 +53,13 @@ export function applyChecklistTemplateToItems( const key = `${String(template._id)}:${templateItemId}` if (existingKeys.has(key)) continue existingKeys.add(key) - const itemType = tplItem.type ?? "checkbox" next.push({ id: generateId(), text, - description: tplItem.description, - type: itemType as ChecklistItemType, - options: itemType === "question" ? tplItem.options : undefined, done: false, required: typeof tplItem.required === "boolean" ? tplItem.required : true, templateId: template._id, templateItemId, - templateDescription: template.description, createdAt: now, createdBy: options.actorId, }) diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 50f2246..9d1d405 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -8,45 +8,6 @@ import { v } from "convex/values" import { renderSimpleNotificationEmailHtml } from "./reactEmail" import { buildBaseUrl } from "./url" -// API do Next.js para verificar preferências -async function sendViaNextApi(params: { - type: string - to: { email: string; name?: string; userId?: string } - subject: string - data: Record - tenantId?: string -}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> { - const baseUrl = buildBaseUrl() - const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET - - if (!token) { - console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente") - return { success: false, reason: "no_token" } - } - - try { - const response = await fetch(`${baseUrl}/api/notifications/send`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(params), - }) - - if (!response.ok) { - const error = await response.text() - console.error("[ticketNotifications] Erro na API:", error) - return { success: false, reason: "api_error" } - } - - return await response.json() - } catch (error) { - console.error("[ticketNotifications] Erro ao chamar API:", error) - return { success: false, reason: "fetch_error" } - } -} - function b64(input: string) { return Buffer.from(input, "utf8").toString("base64") } @@ -320,109 +281,25 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: } } -export const sendTicketCreatedEmail = action({ - args: { - to: v.string(), - userId: v.optional(v.string()), - userName: v.optional(v.string()), - ticketId: v.string(), - reference: v.number(), - subject: v.string(), - priority: v.string(), - tenantId: v.optional(v.string()), - }, - handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => { - const baseUrl = buildBaseUrl() - const url = `${baseUrl}/portal/tickets/${ticketId}` - - const priorityLabels: Record = { - LOW: "Baixa", - MEDIUM: "Média", - HIGH: "Alta", - URGENT: "Urgente", - } - const priorityLabel = priorityLabels[priority] ?? priority - const mailSubject = `Novo chamado #${reference} aberto` - - // Tenta usar a API do Next.js para verificar preferências - const apiResult = await sendViaNextApi({ - type: "ticket_created", - to: { email: to, name: userName, userId }, - subject: mailSubject, - data: { - reference, - subject, - status: "Pendente", - priority: priorityLabel, - viewUrl: url, - }, - tenantId, - }) - - if (apiResult.success || apiResult.skipped) { - return apiResult - } - - // Fallback: envia diretamente se a API falhar - const smtp = buildSmtpConfig() - if (!smtp) { - console.warn("SMTP not configured; skipping ticket created email") - return { skipped: true } - } - - const html = await renderSimpleNotificationEmailHtml({ - title: `Novo chamado #${reference} aberto`, - message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`, - ctaLabel: "Ver chamado", - ctaUrl: url, - }) - await sendSmtpMail(smtp, to, mailSubject, html) - return { ok: true } - }, -}) - export const sendPublicCommentEmail = action({ args: { to: v.string(), - userId: v.optional(v.string()), - userName: v.optional(v.string()), ticketId: v.string(), reference: v.number(), subject: v.string(), - tenantId: v.optional(v.string()), }, - handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => { - const baseUrl = buildBaseUrl() - const url = `${baseUrl}/portal/tickets/${ticketId}` - const mailSubject = `Atualização no chamado #${reference}: ${subject}` - - // Tenta usar a API do Next.js para verificar preferências - const apiResult = await sendViaNextApi({ - type: "comment_public", - to: { email: to, name: userName, userId }, - subject: mailSubject, - data: { - reference, - subject, - viewUrl: url, - }, - tenantId, - }) - - if (apiResult.success || apiResult.skipped) { - return apiResult - } - - // Fallback: envia diretamente se a API falhar + handler: async (_ctx, { to, ticketId, reference, subject }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket comment email") return { skipped: true } } - + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Atualização no chamado #${reference}: ${subject}` const html = await renderSimpleNotificationEmailHtml({ title: `Nova atualização no seu chamado #${reference}`, - message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`, + message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`, ctaLabel: "Abrir e responder", ctaUrl: url, }) @@ -434,45 +311,22 @@ export const sendPublicCommentEmail = action({ export const sendResolvedEmail = action({ args: { to: v.string(), - userId: v.optional(v.string()), - userName: v.optional(v.string()), ticketId: v.string(), reference: v.number(), subject: v.string(), - tenantId: v.optional(v.string()), }, - handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => { - const baseUrl = buildBaseUrl() - const url = `${baseUrl}/portal/tickets/${ticketId}` - const mailSubject = `Seu chamado #${reference} foi encerrado` - - // Tenta usar a API do Next.js para verificar preferências - const apiResult = await sendViaNextApi({ - type: "ticket_resolved", - to: { email: to, name: userName, userId }, - subject: mailSubject, - data: { - reference, - subject, - viewUrl: url, - }, - tenantId, - }) - - if (apiResult.success || apiResult.skipped) { - return apiResult - } - - // Fallback: envia diretamente se a API falhar + handler: async (_ctx, { to, ticketId, reference, subject }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping ticket resolution email") return { skipped: true } } - + const baseUrl = buildBaseUrl() + const url = `${baseUrl}/portal/tickets/${ticketId}` + const mailSubject = `Seu chamado #${reference} foi encerrado` const html = await renderSimpleNotificationEmailHtml({ title: `Chamado #${reference} encerrado`, - message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, + message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`, ctaLabel: "Ver detalhes", ctaUrl: url, }) @@ -485,23 +339,9 @@ export const sendAutomationEmail = action({ args: { to: v.array(v.string()), subject: v.string(), - emailProps: v.object({ - title: v.string(), - message: v.string(), - ticket: v.object({ - reference: v.number(), - subject: v.string(), - status: v.optional(v.union(v.string(), v.null())), - priority: v.optional(v.union(v.string(), v.null())), - companyName: v.optional(v.union(v.string(), v.null())), - requesterName: v.optional(v.union(v.string(), v.null())), - assigneeName: v.optional(v.union(v.string(), v.null())), - }), - ctaLabel: v.string(), - ctaUrl: v.string(), - }), + html: v.string(), }, - handler: async (_ctx, { to, subject, emailProps }) => { + handler: async (_ctx, { to, subject, html }) => { const smtp = buildSmtpConfig() if (!smtp) { console.warn("SMTP not configured; skipping automation email") @@ -517,45 +357,10 @@ export const sendAutomationEmail = action({ return { skipped: true, reason: "no_recipients" } } - // Renderiza o HTML aqui (ambiente Node.js suporta imports dinâmicos) - const { renderAutomationEmailHtml } = await import("./reactEmail") - const html = await renderAutomationEmailHtml({ - title: emailProps.title, - message: emailProps.message, - ticket: { - reference: emailProps.ticket.reference, - subject: emailProps.ticket.subject, - status: emailProps.ticket.status ?? null, - priority: emailProps.ticket.priority ?? null, - companyName: emailProps.ticket.companyName ?? null, - requesterName: emailProps.ticket.requesterName ?? null, - assigneeName: emailProps.ticket.assigneeName ?? null, - }, - ctaLabel: emailProps.ctaLabel, - ctaUrl: emailProps.ctaUrl, - }) - - const results: Array<{ recipient: string; sent: boolean; error?: string }> = [] - for (const recipient of recipients) { - try { - await sendSmtpMail(smtp, recipient, subject, html) - results.push({ recipient, sent: true }) - console.log(`[automation-email] Enviado para ${recipient}`) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - results.push({ recipient, sent: false, error: errorMessage }) - console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`) - } + await sendSmtpMail(smtp, recipient, subject, html) } - const sent = results.filter((r) => r.sent).length - const failed = results.filter((r) => !r.sent).length - - if (failed > 0) { - console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`) - } - - return { ok: sent > 0, sent, failed, results } + return { ok: true, sent: recipients.length } }, }) diff --git a/convex/tickets.ts b/convex/tickets.ts index 27922cc..e2c5602 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -38,7 +38,6 @@ 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}$/; @@ -273,74 +272,25 @@ async function resolveTicketSlaSnapshot( ctx: AnyCtx, tenantId: string, category: Doc<"ticketCategories"> | null, - priority: string, - companyId?: Id<"companies"> | null + priority: string ): Promise { if (!category) { return null; } const normalizedPriority = priority.trim().toUpperCase(); - - // 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado) - let rule: { - responseTargetMinutes?: number; - responseMode?: string; - solutionTargetMinutes?: number; - solutionMode?: string; - alertThreshold?: number; - pauseStatuses?: string[]; - } | null = null; - - if (companyId) { - // Tenta: empresa + categoria + prioridade - rule = await ctx.db - .query("companySlaSettings") - .withIndex("by_tenant_company_category_priority", (q) => - q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority) + const rule = + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) ) - .first(); - - // Fallback: empresa + categoria + DEFAULT - if (!rule) { - rule = await ctx.db - .query("companySlaSettings") - .withIndex("by_tenant_company_category_priority", (q) => - q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", "DEFAULT") - ) - .first(); - } - - // Fallback: empresa + todas categorias (categoryId null) + prioridade - if (!rule) { - const allCategoriesRules = await ctx.db - .query("companySlaSettings") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .filter((q) => q.eq(q.field("categoryId"), undefined)) - .take(10); - - rule = allCategoriesRules.find((r) => r.priority === normalizedPriority) ?? - allCategoriesRules.find((r) => r.priority === "DEFAULT") ?? - null; - } - } - - // 2. Se não encontrou SLA da empresa, usa SLA da categoria (comportamento padrão) - if (!rule) { - rule = - (await ctx.db - .query("categorySlaSettings") - .withIndex("by_tenant_category_priority", (q) => - q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) - ) - .first()) ?? - (await ctx.db - .query("categorySlaSettings") - .withIndex("by_tenant_category_priority", (q) => - q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") - ) - .first()); - } - + .first()) ?? + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") + ) + .first()); if (!rule) { return null; } @@ -922,6 +872,23 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str } } +export function buildAssigneeChangeComment( + reason: string, + context: { previousName: string; nextName: string }, +): string { + const normalized = reason.replace(/\r\n/g, "\n").trim(); + const lines = normalized + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const previous = escapeHtml(context.previousName || "Não atribuído"); + const next = escapeHtml(context.nextName || "Não atribuído"); + const reasonHtml = lines.length + ? lines.map((line) => `

${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)}…` @@ -2131,15 +2098,10 @@ export const getById = query({ ? t.checklist.map((item) => ({ id: item.id, text: item.text, - description: item.description ?? undefined, - type: item.type ?? "checkbox", - options: item.options ?? undefined, - answer: item.answer ?? undefined, done: item.done, required: typeof item.required === "boolean" ? item.required : true, templateId: item.templateId ? String(item.templateId) : undefined, templateItemId: item.templateItemId ?? undefined, - templateDescription: item.templateDescription ?? undefined, createdAt: item.createdAt ?? undefined, createdBy: item.createdBy ? String(item.createdBy) : undefined, doneAt: item.doneAt ?? undefined, @@ -2375,7 +2337,7 @@ export const create = mutation({ avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } - // Resolve a empresa primeiro para poder verificar SLA específico + const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null if (!companyDoc && machineDoc?.companyId) { const candidateCompany = await ctx.db.get(machineDoc.companyId) @@ -2387,8 +2349,6 @@ export const create = mutation({ ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } : undefined const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined - // Resolve SLA passando companyId para verificar regras específicas da empresa - const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority, resolvedCompanyId) let checklist = manualChecklist for (const templateId of args.checklistTemplateIds ?? []) { @@ -2496,28 +2456,6 @@ export const create = mutation({ createdAt: now, }); - // Notificação por e-mail: ticket criado para o solicitante - try { - const requesterEmail = requester?.email - if (requesterEmail) { - const schedulerRunAfter = ctx.scheduler?.runAfter - if (typeof schedulerRunAfter === "function") { - await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, { - to: requesterEmail, - userId: String(requester._id), - userName: requester.name ?? undefined, - ticketId: String(id), - reference: nextRef, - subject, - priority: args.priority, - tenantId: args.tenantId, - }) - } - } - } catch (e) { - console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e) - } - if (initialAssigneeId && initialAssignee) { await ctx.db.insert("ticketEvents", { ticketId: id, @@ -2709,49 +2647,6 @@ export const setChecklistItemRequired = mutation({ }, }); -export const setChecklistItemAnswer = mutation({ - args: { - ticketId: v.id("tickets"), - actorId: v.id("users"), - itemId: v.string(), - answer: v.optional(v.string()), - }, - handler: async (ctx, { ticketId, actorId, itemId, answer }) => { - const ticket = await ctx.db.get(ticketId); - if (!ticket) { - throw new ConvexError("Ticket não encontrado"); - } - const ticketDoc = ticket as Doc<"tickets">; - await requireTicketStaff(ctx, actorId, ticketDoc); - - const checklist = normalizeTicketChecklist(ticketDoc.checklist); - const index = checklist.findIndex((item) => item.id === itemId); - if (index < 0) { - throw new ConvexError("Item do checklist não encontrado."); - } - - const item = checklist[index]!; - if (item.type !== "question") { - throw new ConvexError("Este item não é uma pergunta."); - } - - const now = Date.now(); - const normalizedAnswer = answer?.trim() ?? ""; - const isDone = normalizedAnswer.length > 0; - - const nextChecklist = checklist.map((it) => { - if (it.id !== itemId) return it; - if (isDone) { - return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId }; - } - return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined }; - }); - - await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); - return { ok: true }; - }, -}); - export const removeChecklistItem = mutation({ args: { ticketId: v.id("tickets"), @@ -2805,34 +2700,6 @@ export const completeAllChecklistItems = mutation({ }, }); -export const uncompleteAllChecklistItems = mutation({ - args: { - ticketId: v.id("tickets"), - actorId: v.id("users"), - }, - handler: async (ctx, { ticketId, actorId }) => { - const ticket = await ctx.db.get(ticketId); - if (!ticket) { - throw new ConvexError("Ticket não encontrado"); - } - const ticketDoc = ticket as Doc<"tickets">; - const viewer = await requireTicketStaff(ctx, actorId, ticketDoc); - ensureChecklistEditor(viewer); - - const checklist = normalizeTicketChecklist(ticketDoc.checklist); - if (checklist.length === 0) return { ok: true }; - - const now = Date.now(); - const nextChecklist = checklist.map((item) => { - if (item.done === false) return item; - return { ...item, done: false, doneAt: undefined, doneBy: undefined }; - }); - - await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now }); - return { ok: true }; - }, -}); - export const applyChecklistTemplate = mutation({ args: { ticketId: v.id("tickets"), @@ -2984,19 +2851,15 @@ export const addComment = mutation({ await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch }); // Notificação por e-mail: comentário público para o solicitante try { - const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined - const snapshotEmail = requesterSnapshot?.email + const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { to: snapshotEmail, - userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, - userName: requesterSnapshot?.name ?? undefined, ticketId: String(ticketDoc._id), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", - tenantId: ticketDoc.tenantId, }) } } @@ -3227,18 +3090,7 @@ export async function resolveTicketHandler( throw new ConvexError("Chamado vinculado não encontrado") } - // Buscar prazo de reabertura da empresa do ticket (se existir) - let companyReopenDays: number | null = null - if (ticketDoc.companyId) { - const company = await ctx.db.get(ticketDoc.companyId) - if (company && typeof company.reopenWindowDays === "number") { - companyReopenDays = company.reopenWindowDays - } - } - - // Prioridade: 1) valor passado explicitamente, 2) valor da empresa, 3) padrão - const effectiveReopenDays = reopenWindowDays ?? companyReopenDays - const reopenDays = resolveReopenWindowDays(effectiveReopenDays) + const reopenDays = resolveReopenWindowDays(reopenWindowDays) const reopenDeadline = computeReopenDeadline(now, reopenDays) const normalizedStatus = "RESOLVED" const relatedIdList = Array.from( @@ -3275,21 +3127,16 @@ export async function resolveTicketHandler( // Notificação por e-mail: encerramento do chamado try { - const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null - const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined - const email = requesterDoc?.email || requesterSnapshot?.email || null - const userName = requesterDoc?.name || requesterSnapshot?.name || undefined + const requesterDoc = await ctx.db.get(ticketDoc.requesterId) + const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null if (email) { const schedulerRunAfter = ctx.scheduler?.runAfter if (typeof schedulerRunAfter === "function") { await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { to: email, - userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined, - userName, ticketId: String(ticketId), reference: ticketDoc.reference ?? 0, subject: ticketDoc.subject ?? "", - tenantId: ticketDoc.tenantId, }) } } @@ -3526,6 +3373,38 @@ export const changeAssignee = mutation({ createdAt: now, }); + if (normalizedReason.length > 0) { + const commentBody = buildAssigneeChangeComment(normalizedReason, { + previousName: previousAssigneeName, + nextName: nextAssigneeName, + }) + const commentPlainLength = plainTextLength(commentBody) + if (commentPlainLength > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + const authorSnapshot: CommentAuthorSnapshot = { + name: viewerUser.name, + email: viewerUser.email, + avatarUrl: viewerUser.avatarUrl ?? undefined, + teams: viewerUser.teams ?? undefined, + } + await ctx.db.insert("ticketComments", { + ticketId, + authorId: actorId, + visibility: "INTERNAL", + body: commentBody, + authorSnapshot, + attachments: [], + createdAt: now, + updatedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "COMMENT_ADDED", + payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl }, + createdAt: now, + }) + } }, }); @@ -3855,8 +3734,6 @@ export const postChatMessage = mutation({ await ctx.db.patch(ticketId, { updatedAt: now }) // Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa - // IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions - // O Convex faz retry automatico em caso de OCC conflict const actorRole = participant.role?.toUpperCase() ?? "" if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) { const activeSession = await ctx.db @@ -3866,15 +3743,10 @@ export const postChatMessage = mutation({ .first() if (activeSession) { - // Refetch para garantir valor mais recente (OCC protection) - const freshSession = await ctx.db.get(activeSession._id) - if (freshSession) { - await ctx.db.patch(activeSession._id, { - unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1, - lastActivityAt: now, - lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente - }) - } + await ctx.db.patch(activeSession._id, { + unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1, + lastActivityAt: now, + }) } } diff --git a/convex/users.ts b/convex/users.ts index ab24ef5..e435da6 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -279,86 +279,6 @@ export const deleteUser = mutation({ }, }); -/** - * Atualiza o avatar de um usuário. - * Passa avatarUrl como null para remover o avatar. - * Também atualiza os snapshots em comentários e tickets. - */ -export const updateAvatar = mutation({ - args: { - tenantId: v.string(), - email: v.string(), - avatarUrl: v.union(v.string(), v.null()), - }, - handler: async (ctx, { tenantId, email, avatarUrl }) => { - const user = await ctx.db - .query("users") - .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) - .first() - - if (!user) { - return { status: "not_found" } - } - - // Atualiza o avatar do usuário - usa undefined para remover o campo - const normalizedAvatarUrl = avatarUrl ?? undefined - await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl }) - - // Cria snapshot base sem avatarUrl se for undefined - // Isso garante que o campo seja realmente removido do snapshot - const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = { - name: user.name, - email: user.email, - } - if (normalizedAvatarUrl !== undefined) { - baseSnapshot.avatarUrl = normalizedAvatarUrl - } - if (user.teams && user.teams.length > 0) { - baseSnapshot.teams = user.teams - } - - // Atualiza snapshots em comentários - const comments = await ctx.db - .query("ticketComments") - .withIndex("by_author", (q) => q.eq("authorId", user._id)) - .take(10000) - - if (comments.length > 0) { - await Promise.all( - comments.map(async (comment) => { - await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot }) - }), - ) - } - - // Atualiza snapshots de requester em tickets - const requesterTickets = await ctx.db - .query("tickets") - .withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", user._id)) - .take(10000) - - if (requesterTickets.length > 0) { - for (const t of requesterTickets) { - await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot }) - } - } - - // Atualiza snapshots de assignee em tickets - const assigneeTickets = await ctx.db - .query("tickets") - .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", user._id)) - .take(10000) - - if (assigneeTickets.length > 0) { - for (const t of assigneeTickets) { - await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot }) - } - } - - return { status: "updated", avatarUrl: normalizedAvatarUrl } - }, -}) - export const assignCompany = mutation({ args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") }, handler: async (ctx, { tenantId, email, companyId, actorId }) => { diff --git a/docs/DEPLOY-MANUAL.md b/docs/DEPLOY-MANUAL.md index bb9aa59..4487d5c 100644 --- a/docs/DEPLOY-MANUAL.md +++ b/docs/DEPLOY-MANUAL.md @@ -1,11 +1,11 @@ # Deploy Manual via VPS ## Acesso rápido -- Host: 154.12.253.40 +- Host: 31.220.78.20 - Usuário: root - Caminho do projeto: /srv/apps/sistema - Chave SSH (local): ./codex_ed25519 (chmod 600) -- Login: `ssh -i ./codex_ed25519 root@154.12.253.40` +- Login: `ssh -i ./codex_ed25519 root@31.220.78.20` ## Passo a passo resumido 1. Conectar na VPS usando o comando acima. diff --git a/docs/DEV.md b/docs/DEV.md index e0da9da..2ca05f7 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -1,4 +1,4 @@ -# Guia de Desenvolvimento — 18/12/2025 +# Guia de Desenvolvimento — 18/10/2025 Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções. @@ -6,7 +6,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve - **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback). - **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun. -- **Next.js 16**: Projeto roda em `next@16.0.10` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback. +- **Next.js 16**: Projeto roda em `next@16.0.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback. - **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente. - **Banco DEV**: PostgreSQL local (Docker recomendado). Defina `DATABASE_URL` apontando para seu PostgreSQL. - **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases. @@ -47,7 +47,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve ## Next.js 16 (estável) -- Mantemos o projeto em `next@16.0.10`, com React 19 e o App Router completo. +- Mantemos o projeto em `next@16.0.8`, com React 19 e o App Router completo. - **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas. - **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`. @@ -200,8 +200,8 @@ PY ## Referências úteis -- **Deploy (Swarm)**: veja `docs/OPERATIONS.md`. -- **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.md`. +- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`. +- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. - **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`. > Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. diff --git a/docs/FORGEJO-CI-CD.md b/docs/FORGEJO-CI-CD.md deleted file mode 100644 index fb1cc56..0000000 --- a/docs/FORGEJO-CI-CD.md +++ /dev/null @@ -1,296 +0,0 @@ -# Forgejo CI/CD - Documentacao - -Este documento descreve a configuracao do Forgejo como alternativa ao GitHub Actions para CI/CD self-hosted. - -## Por que Forgejo? - -A partir de marco de 2026, o GitHub passara a cobrar $0.002 por minuto de execucao em self-hosted runners. O Forgejo Actions oferece a mesma experiencia visual e funcionalidade sem custo adicional. - -## Arquitetura - -``` -Claude Code / VS Code - | - Git local - | - git push origin main (GitHub - backup) - git push forgejo main (Forgejo - CI/CD) - | - Forgejo (git.esdrasrenan.com.br) - | - Forgejo Actions (dispara automaticamente) - | - Forgejo Runner (VPS) - | - Docker Swarm deploy -``` - -**Fluxo:** Push para ambos os remotes. O push para `forgejo` dispara o CI/CD. - -```bash -# Push para ambos (recomendado) -git push origin main && git push forgejo main - -# Ou use o alias configurado -git push-all -``` - -## URLs e Credenciais - -| Servico | URL | Usuario | -|---------|-----|---------| -| Forgejo UI | https://git.esdrasrenan.com.br | esdras | -| Forgejo SSH | git@git.esdrasrenan.com.br:2222 | - | -| Actions | https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions | - | - -**Senha inicial:** `ForgejoAdmin2025!` (altere apos primeiro acesso) - -## Estrutura de Arquivos - -``` -projeto/ -├── .forgejo/ -│ └── workflows/ -│ ├── ci-cd-web-desktop.yml # Deploy principal (VPS + Convex) -│ └── quality-checks.yml # Lint, test, build -├── .github/ -│ └── workflows/ # Workflows originais do GitHub -│ └── ... -└── forgejo/ - ├── stack.yml # Stack Docker do Forgejo - └── setup-runner.sh # Script de setup do runner -``` - -## Configuracao na VPS - -### Forgejo Server - -Rodando como servico Docker Swarm: - -```bash -# Localização do stack -/srv/forgejo/stack.yml - -# Comandos uteis -docker service ls --filter "name=forgejo" -docker service logs forgejo_forgejo --tail 100 -docker stack deploy -c /srv/forgejo/stack.yml forgejo -``` - -### Forgejo Runner - -Rodando como servico systemd: - -```bash -# Localização -/srv/forgejo-runner/ - -# Arquivos -/srv/forgejo-runner/forgejo-runner # Binario -/srv/forgejo-runner/config.yaml # Configuracao -/srv/forgejo-runner/.runner # Registro - -# Comandos uteis -systemctl status forgejo-runner -systemctl restart forgejo-runner -journalctl -u forgejo-runner -f - -# Labels do runner -- ubuntu-latest:docker://node:20-bookworm -- self-hosted:host -- linux:host -- vps:host -``` - -## Fluxo de Trabalho - -O repositorio no Forgejo recebe pushes diretos (nao e mais um mirror). - -### Uso diario - -```bash -# Trabalhe normalmente -git add . -git commit -m "sua mensagem" - -# Push para GitHub (backup) e Forgejo (CI/CD) -git push origin main && git push forgejo main - -# Acompanhe o CI/CD em: -# https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions -``` - -### Configurar alias (opcional) - -```bash -# Adicionar alias para push em ambos -git config alias.push-all '!git push origin main && git push forgejo main' - -# Usar: -git push-all -``` - -## Workflows Disponiveis - -### ci-cd-web-desktop.yml - -Triggers: -- Push na branch `main` -- Tags `v*.*.*` -- workflow_dispatch (manual) - -Jobs: -1. **changes** - Detecta arquivos alterados -2. **deploy** - Deploy na VPS (Next.js + Docker Swarm, usando Bun) -3. **convex_deploy** - Deploy das functions Convex -4. ~~**desktop_release**~~ - Build do app desktop (comentado - sem runner Windows) - -### quality-checks.yml - -Triggers: -- Push na branch `main` -- Pull requests para `main` - -Jobs: -1. **lint-test-build** - Lint, testes e build - -## Diferenca do GitHub Actions - -Os workflows do Forgejo sao quase identicos aos do GitHub Actions. Principais diferencas: - -1. **Localizacao:** `.forgejo/workflows/` em vez de `.github/workflows/` - -2. **Actions URL:** Usar `https://github.com/` prefixo nas actions - ```yaml - # GitHub Actions - uses: actions/checkout@v4 - - # Forgejo Actions - uses: https://github.com/actions/checkout@v4 - ``` - -3. **runs-on:** Usar labels do self-hosted runner em vez de `ubuntu-latest` - ```yaml - # GitHub Actions (hosted runner) - runs-on: ubuntu-latest - - # Forgejo Actions (self-hosted) - runs-on: [ self-hosted, linux, vps ] - ``` - -4. **Secrets:** Configurar em Settings > Actions > Secrets no Forgejo - -## Manutencao - -### Atualizar Forgejo - -```bash -ssh root@154.12.253.40 -cd /srv/forgejo -# Editar stack.yml para nova versao da imagem -docker stack deploy -c stack.yml forgejo -``` - -### Atualizar Runner - -```bash -ssh root@154.12.253.40 -cd /srv/forgejo-runner -systemctl stop forgejo-runner - -# Baixar nova versao -RUNNER_VERSION="6.2.2" # ajustar versao -curl -sL -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64" -chmod +x forgejo-runner - -systemctl start forgejo-runner -``` - -### Re-registrar Runner - -Se o runner perder a conexao: - -```bash -ssh root@154.12.253.40 -cd /srv/forgejo-runner - -# Gerar novo token no Forgejo -docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \ - /usr/local/bin/gitea --config /data/gitea/conf/app.ini actions generate-runner-token - -# Re-registrar -systemctl stop forgejo-runner -rm .runner -./forgejo-runner register \ - --instance https://git.esdrasrenan.com.br \ - --token "NOVO_TOKEN" \ - --name "vps-runner" \ - --labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \ - --no-interactive -systemctl start forgejo-runner -``` - -### Backup - -```bash -# Backup do volume do Forgejo -docker run --rm -v forgejo_forgejo_data:/data -v /backup:/backup alpine \ - tar czf /backup/forgejo-backup-$(date +%Y%m%d).tar.gz /data -``` - -## Troubleshooting - -### Runner nao aparece online - -```bash -# Verificar status -systemctl status forgejo-runner -journalctl -u forgejo-runner --no-pager -n 50 - -# Verificar conectividade -curl -s https://git.esdrasrenan.com.br/api/healthz - -# Se o runner mostrar erro "404 Not Found" apos reinicio do Forgejo: -systemctl restart forgejo-runner -``` - -### Workflow nao dispara apos push - -1. Verificar se o arquivo esta em `.forgejo/workflows/` -2. Verificar se Actions esta habilitado no repositorio (Settings > Actions) -3. Verificar se o runner esta online (Settings > Actions > Runners) -4. **Regenerar hooks do repositorio:** - ```bash - docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \ - /usr/local/bin/gitea admin regenerate hooks --config /data/gitea/conf/app.ini - ``` - -### Erro de LevelDB Lock (queue nao inicia) - -Se o Forgejo mostrar erro `unable to lock level db at /data/gitea/queues/common`: - -1. O stack.yml ja usa `FORGEJO__queue__TYPE=channel` para evitar esse problema -2. Se o erro persistir, limpe o diretorio de queues: - ```bash - docker exec $(docker ps -q --filter "name=forgejo_forgejo") \ - rm -rf /data/gitea/queues/* - docker service update --force forgejo_forgejo - ``` - -### Erro de permissao no deploy - -O runner precisa de acesso ao Docker: - -```bash -# Verificar grupo docker -groups runner -# Adicionar se necessario -usermod -aG docker runner -systemctl restart forgejo-runner -``` - -## Referencias - -- [Forgejo Documentation](https://forgejo.org/docs/) -- [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/) -- [Forgejo Runner](https://code.forgejo.org/forgejo/runner) diff --git a/docs/LOCAL-DEV.md b/docs/LOCAL-DEV.md deleted file mode 100644 index 00b4e34..0000000 --- a/docs/LOCAL-DEV.md +++ /dev/null @@ -1,166 +0,0 @@ -# Desenvolvimento Local - -Guia para rodar o projeto localmente conectando aos dados de producao. - -## Pre-requisitos - -- [Bun](https://bun.sh/) 1.3+ -- [Docker](https://www.docker.com/) (para PostgreSQL) -- Node.js 20+ (opcional, usado pelo tsx) - -## 1. Subir o PostgreSQL - -O sistema usa PostgreSQL para autenticacao (Better Auth). Os dados de tickets ficam no Convex. - -```bash -docker run -d \ - --name postgres-chamados \ - -p 5432:5432 \ - -e POSTGRES_PASSWORD=dev \ - -e POSTGRES_DB=sistema_chamados \ - postgres:18 -``` - -Para verificar se esta rodando: - -```bash -docker ps | grep postgres-chamados -``` - -Para parar/iniciar posteriormente: - -```bash -docker stop postgres-chamados -docker start postgres-chamados -``` - -## 2. Configurar variaveis de ambiente - -O arquivo `.env.local` ja vem configurado para desenvolvimento local apontando para o Convex de producao: - -```env -NODE_ENV=development - -# URLs locais -NEXT_PUBLIC_APP_URL=http://localhost:3000 -BETTER_AUTH_URL=http://localhost:3000 - -# Convex de producao (dados reais) -NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br -CONVEX_INTERNAL_URL=https://convex.esdrasrenan.com.br - -# PostgreSQL local (apenas autenticacao) -DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados -``` - -## 3. Instalar dependencias - -```bash -bun install -``` - -## 4. Gerar cliente Prisma e aplicar schema - -```bash -bun run prisma:generate -bunx prisma db push -``` - -## 5. Criar usuarios de desenvolvimento - -O seed cria usuarios locais para autenticacao: - -```bash -DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs -``` - -### Credenciais padrao - -| Usuario | Email | Senha | Role | -|---------------|----------------------|------------|-------| -| Administrador | `admin@sistema.dev` | `admin123` | admin | -| Agentes | `*@rever.com.br` | `agent123` | agent | - -## 6. Iniciar o servidor de desenvolvimento - -```bash -bun run dev:bun -``` - -Acesse: http://localhost:3000 - -## Arquitetura Local vs Producao - -``` -┌─────────────────────────────────────────────────────────────┐ -│ DESENVOLVIMENTO LOCAL │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ localhost:3000 (Next.js) │ -│ │ │ -│ ├──► PostgreSQL local (porta 5432) │ -│ │ └── Autenticacao (Better Auth) │ -│ │ └── Usuarios, sessoes, contas │ -│ │ │ -│ └──► convex.esdrasrenan.com.br (remoto) │ -│ └── Dados de producao │ -│ └── Tickets, empresas, filas, etc. │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Comandos uteis - -| Comando | Descricao | -|---------|-----------| -| `bun run dev:bun` | Inicia servidor de desenvolvimento com Turbopack | -| `bun run build:bun` | Build de producao | -| `bun run lint` | Verificar codigo com ESLint | -| `bun test` | Rodar testes | -| `bunx prisma studio` | Interface visual do banco de dados | - -## Solucao de problemas - -### Erro de conexao com PostgreSQL - -``` -Error: P1001: Can't reach database server at localhost:5432 -``` - -**Solucao:** Verifique se o container Docker esta rodando: - -```bash -docker start postgres-chamados -``` - -### Erro de migracao (tipo DATETIME) - -Se aparecer erro sobre tipo `DATETIME` ao rodar migrations, use `db push` em vez de `migrate`: - -```bash -bunx prisma db push --accept-data-loss -``` - -### Usuario nao consegue logar - -Os usuarios de autenticacao ficam no PostgreSQL local, nao no Convex. Rode o seed novamente: - -```bash -DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs -``` - -### Limpar banco e recriar - -```bash -docker stop postgres-chamados -docker rm postgres-chamados -docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 -bunx prisma db push -DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs -``` - -## Proximos passos - -- Para deploy em producao, consulte `docs/OPERACAO-PRODUCAO.md` -- Para configuracao de SMTP, consulte `docs/SMTP.md` -- Para testes automatizados, consulte `docs/testes-vitest.md` diff --git a/docs/README.md b/docs/README.md index f9e7433..85c2a80 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,6 @@ Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação. ## Visão Geral -- **Desenvolvimento local**: `docs/LOCAL-DEV.md` (setup rapido para rodar localmente) - Operações (produção): `docs/operations.md` - Guia de desenvolvimento: `docs/DEV.md` - Desktop (Tauri): diff --git a/docs/RETENTION-HEALTH.md b/docs/RETENTION-HEALTH.md index 4f8d198..4bcd947 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 (PostgreSQL) guarda todos os tickets; nenhuma rotina remove ou trunca tickets. +- Base quente: Prisma (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets. - Se um dia for preciso offload (ex.: >50k tickets): - Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat). - Gravar um marcador de offload no DB quente (ex.: `ticket_archived_at`, `archive_key`). @@ -33,7 +33,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se ## Checks operacionais sugeridos (manuais) - Tamanho do banco do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "ls -lh /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3"` - Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"` -- Alvos: <100-200 MB para o SQLite do Convex e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual. +- Alvos: <100-200 MB para o SQLite e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual. ## Estado atual e proximos passos - Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes. diff --git a/docs/SETUP.md b/docs/SETUP.md deleted file mode 100644 index 02452c9..0000000 --- a/docs/SETUP.md +++ /dev/null @@ -1,252 +0,0 @@ -# Setup em Novo Computador - -Guia rapido para configurar o ambiente de desenvolvimento em uma nova maquina. - -## Pre-requisitos - -- **Git** instalado -- **Bun** 1.3+ ([bun.sh](https://bun.sh)) -- **Docker** (para PostgreSQL local) -- **Node.js** 20+ (opcional, para algumas ferramentas) - -### Instalar Bun (se ainda nao tiver) - -```bash -# Linux/macOS/WSL -curl -fsSL https://bun.sh/install | bash - -# Windows (PowerShell) -powershell -c "irm bun.sh/install.ps1 | iex" -``` - -## Configurar Autenticacao (Repositorio Privado) - -Se o repositorio for privado, configure autenticacao SSH antes de clonar. - -### Opcao 1: SSH Key (Recomendado) - -```bash -# 1. Gerar chave SSH (se nao tiver) -ssh-keygen -t ed25519 -C "seu-email@exemplo.com" -# Pressione Enter para aceitar o local padrao -# Defina uma senha ou deixe em branco - -# 2. Copiar a chave publica -# Linux/macOS/WSL: -cat ~/.ssh/id_ed25519.pub - -# Windows (PowerShell): -Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub - -# Windows (CMD): -type %USERPROFILE%\.ssh\id_ed25519.pub -``` - -**Adicionar a chave nos servicos:** -- **GitHub:** Settings > SSH and GPG keys > New SSH key -- **Forgejo:** Settings > SSH / GPG Keys > Add Key - -### Opcao 2: Personal Access Token (PAT) - -1. **GitHub:** Settings > Developer settings > Personal access tokens > Tokens (classic) -2. Gerar token com permissao `repo` -3. Usar o token como senha quando o git pedir - -Para salvar o token (nao precisar digitar toda vez): -```bash -git config --global credential.helper store -# Proximo push/pull vai pedir usuario e token, e salvar -``` - -## Setup Rapido - -### 1. Clonar o repositorio - -**Repositorio publico (HTTPS):** -```bash -git clone https://github.com/esdrasrenan/sistema-de-chamados.git -cd sistema-de-chamados -``` - -**Repositorio privado (SSH):** -```bash -git clone git@github.com:esdrasrenan/sistema-de-chamados.git -cd sistema-de-chamados -``` - -Ou se ja tiver o repositorio: -```bash -cd sistema-de-chamados -git pull origin main -``` - -### 2. Configurar remotes (para CI/CD) - -**Repositorio publico (HTTPS):** -```bash -git remote add forgejo https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git -``` - -**Repositorio privado (SSH):** -```bash -# Mudar origin para SSH (se clonou via HTTPS) -git remote set-url origin git@github.com:esdrasrenan/sistema-de-chamados.git - -# Adicionar forgejo via SSH (porta 2222) -git remote add forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git -``` - -**Verificar remotes:** -```bash -git remote -v -# Deve mostrar (exemplo com SSH): -# origin git@github.com:esdrasrenan/sistema-de-chamados.git (fetch) -# origin git@github.com:esdrasrenan/sistema-de-chamados.git (push) -# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (fetch) -# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (push) -``` - -### 3. Instalar dependencias - -```bash -bun install -``` - -### 4. Configurar banco de dados - -```bash -# Subir PostgreSQL via Docker -docker run -d \ - --name postgres-dev \ - -p 5432:5432 \ - -e POSTGRES_PASSWORD=dev \ - -e POSTGRES_DB=sistema_chamados \ - postgres:18 - -# Criar arquivo .env -cp .env.example .env -``` - -Edite o `.env` e configure: - -```env -DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados -BETTER_AUTH_SECRET=sua-chave-secreta-aqui -NEXT_PUBLIC_APP_URL=http://localhost:3000 -BETTER_AUTH_URL=http://localhost:3000 -``` - -### 5. Inicializar o banco - -```bash -# Gerar cliente Prisma -bun run prisma:generate - -# Criar tabelas no banco -bunx prisma db push - -# Popular dados iniciais -bun run auth:seed -``` - -### 6. Rodar o projeto - -```bash -bun run dev:bun -``` - -Acesse: http://localhost:3000 - -**Credenciais padrao:** `admin@sistema.dev` / `admin123` - -## Comandos Uteis - -| Comando | Descricao | -|---------|-----------| -| `bun run dev:bun` | Iniciar servidor de desenvolvimento | -| `bun run build:bun` | Build de producao | -| `bun run lint` | Verificar codigo (ESLint) | -| `bun test` | Rodar testes | -| `bun run prisma:generate` | Gerar cliente Prisma | -| `bunx prisma studio` | Interface visual do banco | - -## Fluxo de Trabalho com Git - -### Push para ambos os remotes (recomendado) - -```bash -# Fazer alteracoes -git add . -git commit -m "sua mensagem" - -# Push para GitHub (backup) e Forgejo (CI/CD) -git push origin main && git push forgejo main -``` - -### Configurar alias para push duplo (opcional) - -```bash -# Criar alias -git config alias.push-all '!git push origin main && git push forgejo main' - -# Usar -git push-all -``` - -## Troubleshooting - -### Erro: "bun: command not found" - -```bash -# Adicionar Bun ao PATH -export PATH="$HOME/.bun/bin:$PATH" - -# Adicionar permanentemente ao ~/.bashrc ou ~/.zshrc -echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc -source ~/.bashrc -``` - -### Erro: Prisma "P2021" / tabelas nao existem - -```bash -bunx prisma db push -bun run auth:seed -``` - -### Erro: Lockfile desatualizado - -```bash -bun install -``` - -### PostgreSQL nao conecta - -```bash -# Verificar se o container esta rodando -docker ps - -# Se nao estiver, iniciar -docker start postgres-dev - -# Ou recriar -docker rm -f postgres-dev -docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18 -``` - -## Convex (Backend de Tempo Real) - -Para desenvolvimento com Convex local: - -```bash -# Terminal 1: Convex dev server -bun run convex:dev:bun - -# Terminal 2: Next.js -bun run dev:bun -``` - -## Mais Informacoes - -- **Desenvolvimento detalhado:** `docs/DEV.md` -- **Deploy e operacoes:** `docs/OPERATIONS.md` -- **CI/CD Forgejo:** `docs/FORGEJO-CI-CD.md` diff --git a/docs/SMTP.md b/docs/SMTP.md index 900df73..e8e3ee2 100644 --- a/docs/SMTP.md +++ b/docs/SMTP.md @@ -15,17 +15,14 @@ Configuracao do servidor de email para envio de notificacoes do sistema. ## Variaveis de Ambiente -Nomes usados pelo sistema (conforme `src/lib/env.ts`): - ```bash -SMTP_ADDRESS=smtp.c.inova.com.br +SMTP_HOST=smtp.c.inova.com.br SMTP_PORT=587 -SMTP_TLS=false -SMTP_ENABLE_STARTTLS_AUTO=true -SMTP_USERNAME=envio@rever.com.br -SMTP_PASSWORD=CAAJQm6ZT6AUdhXRTDYu -SMTP_DOMAIN=rever.com.br -MAILER_SENDER_EMAIL=Sistema de Chamados +SMTP_SECURE=false +SMTP_USER=envio@rever.com.br +SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu +SMTP_FROM_NAME=Sistema de Chamados +SMTP_FROM_EMAIL=envio@rever.com.br ``` ## Exemplo de Uso (Nodemailer) diff --git a/docs/alteracoes-producao-2025-12-18.md b/docs/alteracoes-producao-2025-12-18.md deleted file mode 100644 index 68a0371..0000000 --- a/docs/alteracoes-producao-2025-12-18.md +++ /dev/null @@ -1,54 +0,0 @@ -# Alteracoes de producao - 2025-12-18 - -Este documento registra as mudancas aplicadas na VPS para estabilizar o ambiente e padronizar o uso do PostgreSQL 18. - -## Resumo -- Migracao do banco principal do sistema para o servico `postgres18`. -- Desativacao do servico `postgres` (pg16) no Swarm. -- Convex backend fixado na tag `ghcr.io/get-convex/convex-backend:6690a911bced1e5e516eafc0409a7239fb6541bb`. -- `CONVEX_INTERNAL_URL` ajustado para o endpoint publico, evitando falhas de DNS interno (`ENOTFOUND sistema_convex_backend`). -- Tratamento explicito para tokens revogados/expirados/invalidos nas rotas `/api/machines/*` e chat. -- Limpeza de documento legado no Convex (`liveChatSessions` id `pd71bvfbxx7th3npdj519hcf3s7xbe2j`). - -## Backups gerados -- `/root/pg-backups/sistema_chamados_pg16_20251218215925.dump` -- `/root/pg-backups/sistema_chamados_pg18_20251218215925.dump` -- Convex: `/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717` -- Observacao: foi gerado um arquivo extra `db.sqlite3.backup-` (sem timestamp) por comando incorreto. - -## Procedimento (principais comandos) -``` -# 1) Backup dos bancos -docker exec -u postgres 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 deleted file mode 100644 index 7e135aa..0000000 --- a/docs/alteracoes-producao-2025-12-19.md +++ /dev/null @@ -1,32 +0,0 @@ -# Alteracoes de producao - 2025-12-19 - -Registro das correcoes aplicadas na VPS para reduzir erros em logs e estabilizar certificados e Convex. - -## Traefik / TLS -- ACME alterado de HTTP-01 para TLS-ALPN no servico `traefik_traefik`. -- Reinicio do servico Traefik para aplicar a nova configuracao. - -## Certificados ACME -- Remocao de certificados obsoletos no `acme.json`: - - `pgadmin.rever.com.br` - - `supa.rever.com.br` - - `compressor.esdrasrenan.com.br` -- Backups gerados: - - `/var/lib/docker/volumes/certificados/_data/acme.json.backup-20251219011425` - - `/var/lib/docker/volumes/certificados/_data/acme.json.backup-` (gerado sem timestamp por comando anterior) - -## Convex -- Adicionado `convex_proxy` (tinyproxy) e configurado `--convex-http-proxy` para remover warning de proxy ausente. -- Adicionado `convex_block` (http-echo) para bloquear `POST /api/*` com `Content-Type` nao JSON (415). -- Prioridades de roteamento ajustadas: - - `sistema_convex_api_json` (priority 100) - - `sistema_convex_api_block` (priority 50) - - `sistema_convex` (priority 1) -- `RUST_LOG` ajustado para `info,common::errors=error` a fim de reduzir ruido de warnings nao criticos. - -## Stack / Rede -- Criada rede `convex_internal` (overlay, internal) para trafego interno do Convex com o proxy. -- Arquivo atualizado: `/srv/apps/sistema/stack.yml` (stack `sistema`). - -## Observacoes -- A alteracao do ACME foi feita via `docker service update --args` no Traefik (nao ha stack file versionado). diff --git a/docs/convex-export-worker-loop.md b/docs/convex-export-worker-loop.md index 5812685..a9f3f5b 100644 --- a/docs/convex-export-worker-loop.md +++ b/docs/convex-export-worker-loop.md @@ -112,39 +112,7 @@ Critérios de sucesso: --- -## 6. Registro de alterações manuais - -### 2025-12-18 — liveChatSessions com versão legada (shape_inference) - -Motivo: logs do Convex mostravam `shape_inference` recorrente apontando para o documento -`pd71bvfbxx7th3npdj519hcf3s7xbe2j` (sessão de chat antiga com status `ACTIVE` em versão histórica). - -Comandos executados: - -```bash -# 1) Parar Convex -docker service scale sistema_convex_backend=0 - -# 2) Backup -cp /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3 \ - /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717 - -# 3) Remover versões antigas do documento (mantendo a mais recente) -docker run --rm -v sistema_convex_data:/convex/data nouchka/sqlite3 /convex/data/db.sqlite3 \ - "DELETE FROM documents \ - WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j' \ - AND ts < (SELECT MAX(ts) FROM documents \ - WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j');" - -# 4) Subir Convex -docker service scale sistema_convex_backend=1 -``` - -Resultado: versões antigas do documento foram removidas e os erros de `shape_inference` pararam após o restart. - ---- - -## 7. Referências rápidas +## 6. Referências rápidas - Volume Convex: `sistema_convex_data` - Banco: `/convex/data/db.sqlite3` @@ -154,4 +122,4 @@ Resultado: versões antigas do documento foram removidas e os erros de `shape_in --- -Última revisão: **18/12/2025** — limpeza da versão legada de `liveChatSessions` (`pd71bvfbxx7th3npdj519hcf3s7xbe2j`) e restart do Convex. +Última revisão: **18/11/2025** — sanado por remoção dos registros incompatíveis e rerun bem-sucedido do export `gg20vw5b479d9a2jprjpe3pxg57vk9wa`. diff --git a/docs/diagnostico-chat-desktop-2025-12-19.md b/docs/diagnostico-chat-desktop-2025-12-19.md deleted file mode 100644 index cca63d4..0000000 --- a/docs/diagnostico-chat-desktop-2025-12-19.md +++ /dev/null @@ -1,51 +0,0 @@ -# Diagnostico — Chat do desktop (2025-12-19) - -## Contexto -Relato de instabilidade no chat do desktop (Raven): mensagens enviadas pela web nao chegavam ao app, e com multiplas sessoes a janela travava/nao abria. - -## Evidencias coletadas -- `tickets:getById` confirmou ticket #41048 vinculado a maquina `jn7fc2d5dd8f1qw340ya092k6d7xjrps`, chat habilitado e maquina online. -- `liveChat:getTicketSession` nao tinha sessao ativa antes do teste. -- Teste ponta a ponta via Convex: - - `liveChat:startSession` + `tickets:postChatMessage` criaram sessao e mensagem. - - `liveChat:checkMachineUpdates` retornou `hasActiveSessions=true` e `unreadCount=1`. - - `liveChat:listMachineMessages` retornou a nova mensagem. - - `POST /api/machines/chat/poll` confirmou o mesmo unread. -- Traefik (VPS): nao ha chamadas do desktop para `/api/machines/chat/*` nem `raven-chat/1.0` nas ultimas horas. -- Logs locais do desktop: - - `raven-agent.log` sem entradas `[CHAT DEBUG]`. - - `app.log` sem `chat:started`. - - Com duas sessoes ativas, o log parou em: - - `[CMD] open_chat_window called...` - - `[WINDOW] ... build() inicio` - - sem `build() OK` / `open_chat_window result`, indicando travamento na criacao da janela quando chamada via comando. - -## Causa raiz -O desktop nao estava iniciando o runtime de chat. -Em `apps/desktop/src/main.tsx`, o `invoke("start_chat_polling", ...)` enviava `base_url` e `convex_url` em snake_case. No Tauri v2, o mapeamento esperado e camelCase (`baseUrl`, `convexUrl`). Com isso, o comando falha na desserializacao dos args e o chat nao inicia (sem polling/WebSocket), resultando em nenhuma mensagem chegando ao app. - -Em cenarios com multiplas sessoes, a abertura do segundo chat via hub usa o comando `open_chat_window` (JS). Esse comando era sincrono e rodava no thread principal; ao criar uma nova janela (`WebviewWindowBuilder::build`), a execucao travava e a janela nao concluia o build, congelando o chat no desktop. - -## Correcoes aplicadas -- Ajustado `invoke("start_chat_polling")` para usar `baseUrl` e `convexUrl` (camelCase). -- Tornado `open_chat_window` e `open_hub_window` assíncronos, executando em `spawn_blocking` para evitar bloqueio do thread principal ao criar novas janelas de chat. -- Quando o chat esta aberto e no fim da conversa, o desktop marca automaticamente mensagens como lidas (evita badge preso). -- Ao abrir um chat (foco), outras janelas de chat sao ocultadas e o hub e escondido para evitar sobreposicao. -- Ao minimizar um chat, outras janelas de chat abertas sao ocultadas automaticamente. - -## Arquivos alterados -- `apps/desktop/src/main.tsx` -- `apps/desktop/src-tauri/src/lib.rs` -- `apps/desktop/src-tauri/src/chat.rs` -- `apps/desktop/src/chat/ChatWidget.tsx` - -## Testes recomendados -- `bun run lint` -- `bun test` -- `bun run build:bun` - -## Validacao operativa (pos-build) -1. Abrir o Raven com a maquina online. -2. Enviar mensagem no ticket #41048. -3. Confirmar em `raven-agent.log` a sequencia `[CHAT DEBUG] Iniciando sistema de chat` e eventos `chat:started` em `app.log`. -4. Verificar no Traefik chamadas `/api/machines/chat/poll` ou conexoes WS do Convex com origin `http://tauri.localhost`. diff --git a/emails/_components/ticket-card.tsx b/emails/_components/ticket-card.tsx index d623acc..31ac291 100644 --- a/emails/_components/ticket-card.tsx +++ b/emails/_components/ticket-card.tsx @@ -14,18 +14,6 @@ export type TicketCardData = { assigneeName?: string | null } -export type TicketCardProps = { - ticketNumber: string - ticketTitle: string - status?: string | null - priority?: string | null - category?: string | null - subcategory?: string | null - companyName?: string | null - requesterName?: string | null - assigneeName?: string | null -} - function badge(label: string, bg: string, color: string) { return ( ) } - -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 2555f3c..ebc900c 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 { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card" +import { TicketCard, type TicketCardData } from "./_components/ticket-card" import { normalizeTextToParagraphs } from "./_components/utils" export type AutomationEmailProps = { @@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) { )} - +
-
- -
- - - 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 deleted file mode 100644 index 6e2ef20..0000000 --- a/emails/new-login-email.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import * as React from "react" -import { Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { EMAIL_COLORS } from "./_components/tokens" - -export type NewLoginEmailProps = { - loginAt: string - ipAddress: string - userAgent: string - location?: string | null -} - -function formatDate(dateStr: string): string { - try { - const date = new Date(dateStr) - return new Intl.DateTimeFormat("pt-BR", { - dateStyle: "long", - timeStyle: "short", - }).format(date) - } catch { - return dateStr - } -} - -export default function NewLoginEmail(props: NewLoginEmailProps) { - const { loginAt, ipAddress, userAgent, location } = props - - return ( - -
-
- 🔒 -
-
- - - 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 deleted file mode 100644 index b0db729..0000000 --- a/emails/password-reset-email.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { EMAIL_COLORS } from "./_components/tokens" - -export type PasswordResetEmailProps = { - resetUrl: string - expiresIn?: string -} - -export default function PasswordResetEmail(props: PasswordResetEmailProps) { - const { resetUrl, expiresIn = "1 hora" } = props - - return ( - -
-
- 🔒 -
-
- - - 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 deleted file mode 100644 index a30f916..0000000 --- a/emails/sla-breached-email.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { EMAIL_COLORS } from "./_components/tokens" - -export type SlaBreachedEmailProps = { - ticketNumber: string - ticketTitle: string - breachedAt: string - ticketUrl: string -} - -function formatDate(dateStr: string): string { - try { - const date = new Date(dateStr) - return new Intl.DateTimeFormat("pt-BR", { - dateStyle: "long", - timeStyle: "short", - }).format(date) - } catch { - return dateStr - } -} - -export default function SlaBreachedEmail(props: SlaBreachedEmailProps) { - const { ticketNumber, ticketTitle, breachedAt, ticketUrl } = props - - return ( - -
-
- 🚨 -
-
- - - 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 deleted file mode 100644 index c9d66ee..0000000 --- a/emails/sla-warning-email.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { EMAIL_COLORS } from "./_components/tokens" - -export type SlaWarningEmailProps = { - ticketNumber: string - ticketTitle: string - timeRemaining: string - ticketUrl: string -} - -export default function SlaWarningEmail(props: SlaWarningEmailProps) { - const { ticketNumber, ticketTitle, timeRemaining, ticketUrl } = props - - return ( - -
-
- ⚠ -
-
- - - 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 deleted file mode 100644 index a97ac23..0000000 --- a/emails/ticket-assigned-email.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { TicketCard, type TicketCardProps } from "./_components/ticket-card" -import { EMAIL_COLORS } from "./_components/tokens" - -export type TicketAssignedEmailProps = TicketCardProps & { - ticketUrl: string - assigneeName: string -} - -export default function TicketAssignedEmail(props: TicketAssignedEmailProps) { - const { ticketUrl, assigneeName, ...ticketProps } = props - - return ( - -
-
- 👤 -
-
- - - 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 deleted file mode 100644 index c3cebc2..0000000 --- a/emails/ticket-comment-email.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { EMAIL_COLORS } from "./_components/tokens" - -export type TicketCommentEmailProps = { - ticketNumber: string - ticketTitle: string - commenterName: string - commentPreview: string - ticketUrl: string -} - -export default function TicketCommentEmail(props: TicketCommentEmailProps) { - const { ticketNumber, ticketTitle, commenterName, commentPreview, ticketUrl } = props - - return ( - -
-
- 💬 -
-
- - - 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 deleted file mode 100644 index ff187c8..0000000 --- a/emails/ticket-created-email.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { TicketCard, type TicketCardProps } from "./_components/ticket-card" -import { EMAIL_COLORS } from "./_components/tokens" - -export type TicketCreatedEmailProps = TicketCardProps & { - ticketUrl: string -} - -export default function TicketCreatedEmail(props: TicketCreatedEmailProps) { - const { ticketUrl, ...ticketProps } = props - - return ( - -
-
- ✅ -
-
- - - 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 deleted file mode 100644 index a1e84e3..0000000 --- a/emails/ticket-resolved-email.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { TicketCard, type TicketCardProps } from "./_components/ticket-card" -import { EMAIL_COLORS } from "./_components/tokens" - -export type TicketResolvedEmailProps = TicketCardProps & { - ticketUrl: string - ratingUrl?: string | null - resolution?: string | null -} - -export default function TicketResolvedEmail(props: TicketResolvedEmailProps) { - const { ticketUrl, ratingUrl, resolution, ...ticketProps } = props - - return ( - -
-
- 🎉 -
-
- - - 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 deleted file mode 100644 index 2d50d45..0000000 --- a/emails/ticket-status-email.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from "react" -import { Button, Heading, Hr, Section, Text } from "@react-email/components" - -import { RavenEmailLayout } from "./_components/layout" -import { TicketCard, type TicketCardProps } from "./_components/ticket-card" -import { EMAIL_COLORS } from "./_components/tokens" -import { formatStatus } from "./_components/utils" - -export type TicketStatusEmailProps = TicketCardProps & { - ticketUrl: string - previousStatus: string - newStatus: string -} - -export default function TicketStatusEmail(props: TicketStatusEmailProps) { - const { ticketUrl, previousStatus, newStatus, ...ticketProps } = props - - return ( - -
-
- 🔄 -
-
- - - 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 86d1e3c..e765ea5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,6 @@ 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 deleted file mode 100644 index 05f3f23..0000000 --- a/forgejo/setup-runner.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/bash -# Script para configurar o Forgejo Runner -# Execute na VPS apos o Forgejo estar rodando - -set -e - -FORGEJO_URL="${FORGEJO_URL:-https://git.esdrasrenan.com.br}" -RUNNER_NAME="${RUNNER_NAME:-vps-runner}" -RUNNER_DIR="/srv/forgejo-runner" -CONFIG_FILE="$RUNNER_DIR/config.yml" - -echo "=== Configuracao do Forgejo Runner ===" -echo "" -echo "1. Acesse o Forgejo: $FORGEJO_URL" -echo "2. Va em: Site Administration > Actions > Runners" -echo "3. Clique em 'Create new Runner'" -echo "4. Copie o token de registro" -echo "" -read -p "Cole o token de registro aqui: " REGISTRATION_TOKEN - -if [ -z "$REGISTRATION_TOKEN" ]; then - echo "ERRO: Token nao pode ser vazio" - exit 1 -fi - -# Criar diretorio do runner -mkdir -p "$RUNNER_DIR" -cd "$RUNNER_DIR" - -# Baixar o runner se nao existir -if [ ! -f "./forgejo-runner" ]; then - echo "Baixando Forgejo Runner..." - RUNNER_VERSION="6.2.2" - curl -L -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64" - chmod +x forgejo-runner -fi - -# Registrar o runner -echo "Registrando runner..." -./forgejo-runner register \ - --instance "$FORGEJO_URL" \ - --token "$REGISTRATION_TOKEN" \ - --name "$RUNNER_NAME" \ - --labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \ - --no-interactive - -# Criar config.yml customizado -cat > "$CONFIG_FILE" << 'EOF' -log: - level: info - -runner: - file: .runner - capacity: 2 - timeout: 3h - insecure: false - fetch_timeout: 5s - fetch_interval: 2s - labels: - - "ubuntu-latest:docker://node:20-bookworm" - - "self-hosted:host" - - "linux:host" - - "vps:host" - -cache: - enabled: true - dir: /tmp/forgejo-runner-cache - host: "" - port: 0 - external_server: "" - -container: - network: "host" - privileged: false - options: "" - workdir_parent: /tmp/forgejo-runner-workdir - valid_volumes: - - /var/run/docker.sock - - /home/runner/apps - - /srv/apps - - /tmp - docker_host: "" - force_pull: false - -host: - workdir_parent: /tmp/forgejo-runner-workdir -EOF - -echo "" -echo "=== Runner registrado com sucesso! ===" -echo "" -echo "Para iniciar o runner como servico systemd, execute:" -echo "" -echo "sudo tee /etc/systemd/system/forgejo-runner.service << 'SYSTEMD' -[Unit] -Description=Forgejo Runner -After=docker.service network.target - -[Service] -Type=simple -User=runner -WorkingDirectory=$RUNNER_DIR -ExecStart=$RUNNER_DIR/forgejo-runner daemon --config $CONFIG_FILE -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target -SYSTEMD" -echo "" -echo "sudo systemctl daemon-reload" -echo "sudo systemctl enable forgejo-runner" -echo "sudo systemctl start forgejo-runner" diff --git a/forgejo/stack.yml b/forgejo/stack.yml deleted file mode 100644 index 375ee29..0000000 --- a/forgejo/stack.yml +++ /dev/null @@ -1,89 +0,0 @@ -version: "3.8" - -# Forgejo para CI/CD self-hosted -# Substitui o GitHub Actions sem perder a experiencia visual -# NOTA: O runner roda como servico systemd, nao como container no Swarm - -services: - forgejo: - image: codeberg.org/forgejo/forgejo:11 - environment: - - USER_UID=1000 - - USER_GID=1000 - # Configuracoes do Forgejo - - FORGEJO__database__DB_TYPE=sqlite3 - - FORGEJO__database__PATH=/data/gitea/forgejo.db - - FORGEJO__server__DOMAIN=git.esdrasrenan.com.br - - FORGEJO__server__ROOT_URL=https://git.esdrasrenan.com.br/ - - FORGEJO__server__SSH_DOMAIN=git.esdrasrenan.com.br - - FORGEJO__server__SSH_PORT=2222 - - FORGEJO__server__HTTP_PORT=3000 - - FORGEJO__server__OFFLINE_MODE=false - # Actions habilitado - - FORGEJO__actions__ENABLED=true - - FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org - # Seguranca - INSTALL_LOCK=true apos instalacao inicial - - FORGEJO__security__INSTALL_LOCK=true - - FORGEJO__service__DISABLE_REGISTRATION=true - # Queue - usar channel em vez de leveldb para evitar problemas de lock - - FORGEJO__queue__TYPE=channel - - FORGEJO__queue__DATADIR=queues/ - # Logs - - FORGEJO__log__MODE=console - - FORGEJO__log__LEVEL=Info - volumes: - - forgejo_data:/data - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - networks: - - traefik_public - - forgejo_internal - ports: - # SSH para git clone via SSH (exposto diretamente) - - "2222:2222" - deploy: - mode: replicated - replicas: 1 - update_config: - parallelism: 1 - order: start-first - failure_action: rollback - delay: 10s - monitor: 30s - resources: - limits: - memory: "1G" - reservations: - memory: "256M" - restart_policy: - condition: any - delay: 5s - max_attempts: 3 - window: 120s - placement: - constraints: - - node.role == manager - labels: - - traefik.enable=true - - traefik.docker.network=traefik_public - # Web UI - - traefik.http.routers.forgejo.rule=Host(`git.esdrasrenan.com.br`) - - traefik.http.routers.forgejo.entrypoints=websecure - - traefik.http.routers.forgejo.tls=true - - traefik.http.routers.forgejo.tls.certresolver=le - - traefik.http.services.forgejo.loadbalancer.server.port=3000 - healthcheck: - test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/healthz"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - -volumes: - forgejo_data: - -networks: - traefik_public: - external: true - forgejo_internal: - driver: overlay diff --git a/package.json b/package.json index e62d82b..186a9fa 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.7", - "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh deleted file mode 100644 index 703411f..0000000 --- a/scripts/setup-dev.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/bash -# Script de setup para ambiente de desenvolvimento -# Uso: ./scripts/setup-dev.sh [--ssh] -# -# Opcoes: -# --ssh Configurar remotes usando SSH (para repositorio privado) - -set -e - -# Verificar se deve usar SSH -USE_SSH=false -if [ "$1" = "--ssh" ]; then - USE_SSH=true -fi - -echo "=== Setup do Ambiente de Desenvolvimento ===" -echo "" - -# Cores para output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -# Funcao para printar status -ok() { echo -e "${GREEN}[OK]${NC} $1"; } -warn() { echo -e "${YELLOW}[AVISO]${NC} $1"; } -err() { echo -e "${RED}[ERRO]${NC} $1"; } -info() { echo -e "${CYAN}[INFO]${NC} $1"; } - -# 1. Verificar pre-requisitos -echo "1. Verificando pre-requisitos..." - -# Verificar Bun -if command -v bun &> /dev/null; then - BUN_VERSION=$(bun --version) - ok "Bun instalado: v$BUN_VERSION" -else - err "Bun nao encontrado!" - echo " Instale com: curl -fsSL https://bun.sh/install | bash" - exit 1 -fi - -# Verificar Docker -if command -v docker &> /dev/null; then - ok "Docker instalado" -else - warn "Docker nao encontrado. Voce precisara configurar o PostgreSQL manualmente." -fi - -# Verificar Git -if command -v git &> /dev/null; then - ok "Git instalado" -else - err "Git nao encontrado!" - exit 1 -fi - -# Verificar SSH key (se usando SSH) -if [ "$USE_SSH" = true ]; then - echo "" - echo "1.1. Verificando chave SSH..." - if [ -f "$HOME/.ssh/id_ed25519.pub" ] || [ -f "$HOME/.ssh/id_rsa.pub" ]; then - ok "Chave SSH encontrada" - echo " Certifique-se de que a chave esta adicionada no GitHub e Forgejo" - else - warn "Chave SSH nao encontrada!" - echo "" - echo " Para criar uma chave SSH:" - echo " ssh-keygen -t ed25519 -C \"seu-email@exemplo.com\"" - echo "" - echo " Depois adicione a chave publica em:" - echo " - GitHub: Settings > SSH and GPG keys > New SSH key" - echo " - Forgejo: Settings > SSH / GPG Keys > Add Key" - echo "" - read -p " Deseja continuar mesmo assim? (s/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Ss]$ ]]; then - exit 1 - fi - fi -fi - -echo "" - -# 2. Configurar remotes do Git -echo "2. Configurando remotes do Git..." - -# Verificar se estamos em um repositorio git -if [ ! -d ".git" ]; then - err "Este diretorio nao e um repositorio Git!" - exit 1 -fi - -# URLs dos remotes -if [ "$USE_SSH" = true ]; then - ORIGIN_URL="git@github.com:esdrasrenan/sistema-de-chamados.git" - FORGEJO_URL="ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git" - info "Usando SSH para os remotes (repositorio privado)" -else - ORIGIN_URL="https://github.com/esdrasrenan/sistema-de-chamados.git" - FORGEJO_URL="https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git" - info "Usando HTTPS para os remotes (repositorio publico)" -fi - -# Configurar/atualizar origin -CURRENT_ORIGIN=$(git remote get-url origin 2>/dev/null || echo "") -if [ "$CURRENT_ORIGIN" != "$ORIGIN_URL" ]; then - if [ -n "$CURRENT_ORIGIN" ]; then - git remote set-url origin "$ORIGIN_URL" - ok "Remote 'origin' atualizado para $ORIGIN_URL" - fi -else - ok "Remote 'origin' ja configurado corretamente" -fi - -# Verificar/adicionar remote forgejo -if git remote get-url forgejo &> /dev/null; then - CURRENT_FORGEJO=$(git remote get-url forgejo) - if [ "$CURRENT_FORGEJO" != "$FORGEJO_URL" ]; then - git remote set-url forgejo "$FORGEJO_URL" - ok "Remote 'forgejo' atualizado para $FORGEJO_URL" - else - ok "Remote 'forgejo' ja configurado corretamente" - fi -else - git remote add forgejo "$FORGEJO_URL" - ok "Remote 'forgejo' adicionado" -fi - -# Mostrar remotes -echo " Remotes configurados:" -git remote -v | sed 's/^/ /' - -echo "" - -# 3. Instalar dependencias -echo "3. Instalando dependencias..." -bun install -ok "Dependencias instaladas" - -echo "" - -# 4. Configurar arquivo .env -echo "4. Configurando arquivo .env..." - -if [ -f ".env" ]; then - warn "Arquivo .env ja existe. Pulando..." -else - if [ -f ".env.example" ]; then - cp .env.example .env - ok "Arquivo .env criado a partir do .env.example" - warn "IMPORTANTE: Edite o arquivo .env com suas configuracoes!" - else - # Criar .env basico - cat > .env << 'EOF' -DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados -BETTER_AUTH_SECRET=dev-secret-change-in-production -NEXT_PUBLIC_APP_URL=http://localhost:3000 -BETTER_AUTH_URL=http://localhost:3000 -NEXT_PUBLIC_CONVEX_URL=http://localhost:3210 -EOF - ok "Arquivo .env criado com valores padrao para desenvolvimento" - warn "IMPORTANTE: Ajuste as configuracoes conforme necessario!" - fi -fi - -echo "" - -# 5. Configurar PostgreSQL via Docker -echo "5. Configurando PostgreSQL..." - -if command -v docker &> /dev/null; then - if docker ps -a --format '{{.Names}}' | grep -q '^postgres-dev$'; then - # Container existe, verificar se esta rodando - if docker ps --format '{{.Names}}' | grep -q '^postgres-dev$'; then - ok "PostgreSQL ja esta rodando" - else - docker start postgres-dev - ok "PostgreSQL iniciado" - fi - else - # Criar container - docker run -d \ - --name postgres-dev \ - -p 5432:5432 \ - -e POSTGRES_PASSWORD=dev \ - -e POSTGRES_DB=sistema_chamados \ - postgres:16 - ok "PostgreSQL criado e iniciado" - echo " Aguardando PostgreSQL inicializar..." - sleep 3 - fi -else - warn "Docker nao disponivel. Configure o PostgreSQL manualmente." - echo " DATABASE_URL deve apontar para seu servidor PostgreSQL" -fi - -echo "" - -# 6. Gerar cliente Prisma -echo "6. Gerando cliente Prisma..." -bun run prisma:generate -ok "Cliente Prisma gerado" - -echo "" - -# 7. Inicializar banco de dados -echo "7. Inicializando banco de dados..." - -# Verificar se o banco esta acessivel -if bunx prisma db push --skip-generate 2>/dev/null; then - ok "Schema do banco atualizado" - - # Seed inicial - echo " Populando dados iniciais..." - if bun run auth:seed 2>/dev/null; then - ok "Dados iniciais criados" - else - warn "Seed falhou ou ja foi executado anteriormente" - fi -else - warn "Nao foi possivel conectar ao banco de dados" - echo " Verifique se o PostgreSQL esta rodando e as credenciais no .env" -fi - -echo "" - -# 8. Configurar alias do Git (opcional) -echo "8. Configurando alias do Git..." - -if git config --get alias.push-all &> /dev/null; then - ok "Alias 'push-all' ja configurado" -else - git config alias.push-all '!git push origin main && git push forgejo main' - ok "Alias 'push-all' criado (use: git push-all)" -fi - -echo "" -echo "=== Setup Concluido! ===" -echo "" -echo "Proximos passos:" -echo " 1. Verifique/edite o arquivo .env" -echo " 2. Execute: bun run dev:bun" -echo " 3. Acesse: http://localhost:3000" -echo " 4. Login: admin@sistema.dev / admin123" -echo "" -echo "Para fazer deploy:" -echo " git push origin main && git push forgejo main" -echo " ou: git push-all" -echo "" diff --git a/scripts/test-all-emails.tsx b/scripts/test-all-emails.tsx deleted file mode 100644 index ef9929a..0000000 --- a/scripts/test-all-emails.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import * as React from "react" -import dotenv from "dotenv" -import { render } from "@react-email/render" - -import { sendSmtpMail } from "@/server/email-smtp" -import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email" -import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email" - -dotenv.config({ path: ".env.local" }) -dotenv.config({ path: ".env" }) - -function getSmtpConfig() { - const host = process.env.SMTP_HOST - const port = process.env.SMTP_PORT - const username = process.env.SMTP_USER - const password = process.env.SMTP_PASS - const fromEmail = process.env.SMTP_FROM_EMAIL - const fromName = process.env.SMTP_FROM_NAME ?? "Raven" - - if (!host || !port || !username || !password || !fromEmail) return null - - return { - host, - port: Number(port), - username, - password, - from: `"${fromName}" <${fromEmail}>`, - tls: process.env.SMTP_SECURE === "true", - rejectUnauthorized: false, - timeoutMs: 15000, - } -} - -type EmailScenario = { - name: string - subject: string - render: () => Promise -} - -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 deleted file mode 100644 index 64a375e..0000000 --- a/scripts/test-email.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Script para testar envio de e-mail - * Uso: bun scripts/test-email.ts [destinatario] - */ - -import { sendSmtpMail } from "../src/server/email-smtp" -import { renderTemplate } from "../src/server/email/email-templates" - -const DESTINATARIO = process.argv[2] || "renan.pac@paulicon.com.br" - -// Credenciais do SMTP (usando as da documentacao) -const smtpConfig = { - host: "smtp.c.inova.com.br", - port: 587, - username: "envio@rever.com.br", - password: "CAAJQm6ZT6AUdhXRTDYu", - from: '"Sistema de Chamados" ', - 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 515356c..af2fd9b 100644 --- a/scripts/utils/prisma-client.mjs +++ b/scripts/utils/prisma-client.mjs @@ -1,24 +1,63 @@ -import pg from "pg" +import path from "node:path" // NOTE: This helper imports the generated Prisma client from TypeScript files. // Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun). import { PrismaClient } from "../../src/generated/prisma/client.ts" -import { PrismaPg } from "@prisma/adapter-pg" +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3" -const { Pool } = pg +const PROJECT_ROOT = process.cwd() +const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma") -export function createPrismaClient() { - const databaseUrl = process.env.DATABASE_URL - - if (!databaseUrl) { - throw new Error("DATABASE_URL environment variable is required") +function resolveFileUrl(url) { + if (!url.startsWith("file:")) { + return url } - const pool = new Pool({ - connectionString: databaseUrl, - }) + const filePath = url.slice("file:".length) - const adapter = new PrismaPg(pool) + if (filePath.startsWith("//")) { + return url + } + + if (path.isAbsolute(filePath)) { + return `file:${path.normalize(filePath)}` + } + + const normalized = path.normalize(filePath) + const prismaPrefix = `prisma${path.sep}` + const relativeToPrisma = normalized.startsWith(prismaPrefix) + ? normalized.slice(prismaPrefix.length) + : normalized + + const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma) + + if (!absolutePath.startsWith(PROJECT_ROOT)) { + throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`) + } + + return `file:${absolutePath}` +} + +function normalizeDatasourceUrl(envUrl) { + const trimmed = envUrl?.trim() + if (trimmed) { + return resolveFileUrl(trimmed) + } + + if (process.env.NODE_ENV === "production") { + return "file:/app/data/db.sqlite" + } + + return resolveFileUrl("file:./db.dev.sqlite") +} + +export function createPrismaClient() { + const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL) + process.env.DATABASE_URL = resolvedDatabaseUrl + + const adapter = new PrismaBetterSqlite3({ + url: resolvedDatabaseUrl, + }) return new PrismaClient({ adapter }) } diff --git a/src/app/api/admin/fix-chat-sessions/route.ts b/src/app/api/admin/fix-chat-sessions/route.ts deleted file mode 100644 index 3ed362c..0000000 --- a/src/app/api/admin/fix-chat-sessions/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server" -import { ConvexHttpClient } from "convex/browser" -import { api } from "@/convex/_generated/api" -import { assertAdminSession } from "@/lib/auth-server" - -export const runtime = "nodejs" - -export async function POST() { - const session = await assertAdminSession() - if (!session) { - return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) - } - - const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return NextResponse.json({ error: "CONVEX_URL não configurada" }, { status: 500 }) - } - - try { - const convex = new ConvexHttpClient(convexUrl) - const result = await convex.mutation(api.liveChat.fixLegacySessions, {}) - return NextResponse.json({ success: true, result }) - } catch (error) { - console.error("[fix-chat-sessions] Erro:", error) - return NextResponse.json( - { error: error instanceof Error ? error.message : "Falha ao corrigir sessões" }, - { status: 500 } - ) - } -} diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 6093176..958513e 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -10,8 +10,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants" import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" -import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" -import { notifyUserInvite } from "@/server/notification/notification-service" +import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" const DEFAULT_EXPIRATION_DAYS = 7 const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput @@ -28,17 +27,6 @@ function normalizeRole(input: string | null | undefined): RoleOption { return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" } -const ROLE_LABELS: Record = { - admin: "Administrador", - manager: "Gestor", - agent: "Agente", - collaborator: "Colaborador", -} - -function formatRoleName(role: string): string { - return ROLE_LABELS[role.toLowerCase()] ?? role -} - function generateToken() { return randomBytes(32).toString("hex") } @@ -225,24 +213,5 @@ export async function POST(request: Request) { const normalized = buildInvitePayload(inviteWithEvents, now) await syncInviteWithConvex(normalized) - // Envia email de convite - const inviteUrl = buildInviteUrl(token) - const inviterName = session.user.name ?? session.user.email - const roleName = formatRoleName(role) - - try { - await notifyUserInvite( - email, - name ?? null, - inviterName, - roleName, - null, // companyName - não temos essa informação no convite - inviteUrl - ) - } catch (error) { - // Log do erro mas não falha a criação do convite - console.error("[invites] Falha ao enviar email de convite:", error) - } - return NextResponse.json({ invite: normalized }) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 21af431..c3c624d 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: { id: createdAuthUser.id }, + where: { email }, update: { name, role: userRole, @@ -213,7 +213,6 @@ export async function POST(request: Request) { managerId: managerRecord?.id ?? null, }, create: { - id: createdAuthUser.id, name, email, role: userRole, diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts deleted file mode 100644 index dd2ed23..0000000 --- a/src/app/api/auth/forgot-password/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -import crypto from "crypto" - -import { render } from "@react-email/render" -import { NextResponse } from "next/server" - -import { prisma } from "@/lib/prisma" -import { sendSmtpMail } from "@/server/email-smtp" -import SimpleNotificationEmail from "../../../../../emails/simple-notification-email" - -function getSmtpConfig() { - const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS - const port = process.env.SMTP_PORT - const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME - const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD - const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL - const fromName = process.env.SMTP_FROM_NAME ?? "Raven" - - if (!host || !port || !username || !password || !fromEmail) return null - - return { - host, - port: Number(port), - username, - password, - from: `"${fromName}" <${fromEmail}>`, - tls: process.env.SMTP_SECURE === "true", - starttls: process.env.SMTP_SECURE !== "true", - rejectUnauthorized: false, - timeoutMs: 15000, - } -} - -export async function POST(request: Request) { - try { - const body = await request.json() - const { email } = body - - if (!email || typeof email !== "string") { - return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 }) - } - - const normalizedEmail = email.toLowerCase().trim() - - // Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança) - const user = await prisma.authUser.findFirst({ - where: { email: normalizedEmail }, - }) - - // Sempre retorna sucesso para não revelar se o e-mail existe - if (!user) { - return NextResponse.json({ success: true }) - } - - // Gera um token seguro - const token = crypto.randomBytes(32).toString("hex") - const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora - - // Remove tokens anteriores do mesmo usuário - await prisma.authVerification.deleteMany({ - where: { - identifier: `password-reset:${user.id}`, - }, - }) - - // Salva o novo token - await prisma.authVerification.create({ - data: { - identifier: `password-reset:${user.id}`, - value: token, - expiresAt, - }, - }) - - // Envia o e-mail - const smtp = getSmtpConfig() - if (!smtp) { - console.error("[FORGOT_PASSWORD] SMTP não configurado") - return NextResponse.json({ success: true }) // Não revela erro de configuração - } - - const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br" - const resetUrl = `${baseUrl}/redefinir-senha?token=${token}` - - const html = await render( - SimpleNotificationEmail({ - title: "Redefinição de Senha", - message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`, - ctaLabel: "Redefinir Senha", - ctaUrl: resetUrl, - }), - { pretty: true } - ) - - await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error("[FORGOT_PASSWORD] Erro:", error) - return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 }) - } -} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts deleted file mode 100644 index 2c7fe6e..0000000 --- a/src/app/api/auth/reset-password/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { NextResponse } from "next/server" -import { hashPassword } from "better-auth/crypto" - -import { prisma } from "@/lib/prisma" - -export async function POST(request: Request) { - try { - const body = await request.json() - const { token, password } = body - - if (!token || typeof token !== "string") { - return NextResponse.json({ error: "Token inválido" }, { status: 400 }) - } - - if (!password || typeof password !== "string" || password.length < 6) { - return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 }) - } - - // Busca o token de verificação - const verification = await prisma.authVerification.findFirst({ - where: { - value: token, - identifier: { startsWith: "password-reset:" }, - expiresAt: { gt: new Date() }, - }, - }) - - if (!verification) { - return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 }) - } - - // Extrai o userId do identifier - const userId = verification.identifier.replace("password-reset:", "") - - // Busca o usuário - const user = await prisma.authUser.findUnique({ - where: { id: userId }, - }) - - if (!user) { - return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 }) - } - - // Hash da nova senha - const hashedPassword = await hashPassword(password) - - // Atualiza a conta do usuário com a nova senha - await prisma.authAccount.updateMany({ - where: { - userId: user.id, - providerId: "credential", - }, - data: { - password: hashedPassword, - }, - }) - - // Remove o token usado - await prisma.authVerification.delete({ - where: { id: verification.id }, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error("[RESET_PASSWORD] Erro:", error) - return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 }) - } -} - -// GET para validar se o token é válido (usado pela página) -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url) - const token = searchParams.get("token") - - if (!token) { - return NextResponse.json({ valid: false, error: "Token não fornecido" }) - } - - const verification = await prisma.authVerification.findFirst({ - where: { - value: token, - identifier: { startsWith: "password-reset:" }, - expiresAt: { gt: new Date() }, - }, - }) - - if (!verification) { - return NextResponse.json({ valid: false, error: "Token inválido ou expirado" }) - } - - return NextResponse.json({ valid: true }) - } catch (error) { - console.error("[RESET_PASSWORD] Erro ao validar token:", error) - return NextResponse.json({ valid: false, error: "Erro ao validar token" }) - } -} diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts index 2fff5cc..bef5551 100644 --- a/src/app/api/invites/[token]/route.ts +++ b/src/app/api/invites/[token]/route.ts @@ -157,23 +157,6 @@ 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 ed7feb3..d94d458 100644 --- a/src/app/api/machines/chat/attachments/url/route.ts +++ b/src/app/api/machines/chat/attachments/url/route.ts @@ -4,7 +4,6 @@ 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({ @@ -88,16 +87,6 @@ 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 74019d5..e431795 100644 --- a/src/app/api/machines/chat/messages/route.ts +++ b/src/app/api/machines/chat/messages/route.ts @@ -4,7 +4,6 @@ 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" @@ -116,15 +115,6 @@ 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) @@ -169,15 +159,6 @@ 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 f84bfde..c3e009c 100644 --- a/src/app/api/machines/chat/poll/route.ts +++ b/src/app/api/machines/chat/poll/route.ts @@ -3,7 +3,6 @@ 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({ @@ -69,16 +68,6 @@ 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 fdc6fa0..d31ba72 100644 --- a/src/app/api/machines/chat/read/route.ts +++ b/src/app/api/machines/chat/read/route.ts @@ -4,7 +4,6 @@ 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({ @@ -70,18 +69,9 @@ 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 0ab0474..431bcc2 100644 --- a/src/app/api/machines/chat/sessions/route.ts +++ b/src/app/api/machines/chat/sessions/route.ts @@ -3,7 +3,6 @@ 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({ @@ -67,16 +66,6 @@ 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 183528a..e86a01d 100644 --- a/src/app/api/machines/chat/stream/route.ts +++ b/src/app/api/machines/chat/stream/route.ts @@ -1,7 +1,6 @@ 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" @@ -46,10 +45,9 @@ export async function GET(request: Request) { try { await client.query(api.liveChat.checkMachineUpdates, { machineToken: token }) } catch (error) { - const tokenError = resolveMachineTokenError(error) - const message = tokenError?.message ?? (error instanceof Error ? error.message : "Token invalido") + const message = error instanceof Error ? error.message : "Token invalido" return new Response(message, { - status: tokenError?.status ?? 401, + status: 401, headers: { "Access-Control-Allow-Origin": resolvedOrigin, "Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false", @@ -112,15 +110,6 @@ 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 1055c5a..8a231be 100644 --- a/src/app/api/machines/chat/upload/route.ts +++ b/src/app/api/machines/chat/upload/route.ts @@ -3,7 +3,6 @@ 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), @@ -61,15 +60,6 @@ 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 068f37b..cb520ee 100644 --- a/src/app/api/machines/heartbeat/route.ts +++ b/src/app/api/machines/heartbeat/route.ts @@ -3,7 +3,6 @@ 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), @@ -60,15 +59,6 @@ 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 11ac5f9..2ae6fb1 100644 --- a/src/app/api/machines/inventory/route.ts +++ b/src/app/api/machines/inventory/route.ts @@ -3,7 +3,6 @@ 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), @@ -78,15 +77,6 @@ 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) @@ -104,8 +94,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) { @@ -117,4 +107,3 @@ 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 a0027af..c5718c1 100644 --- a/src/app/api/machines/remote-access/route.ts +++ b/src/app/api/machines/remote-access/route.ts @@ -3,7 +3,6 @@ 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" @@ -55,15 +54,6 @@ 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 d97bc18..a7c19c7 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -2,7 +2,6 @@ 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, @@ -128,23 +127,13 @@ export async function POST(request: Request) { } catch (error) { if (error instanceof MachineInactiveError) { return jsonWithCors( - { error: "Dispositivo desativado. Entre em contato com o suporte da Rever para reativar o acesso." }, + { error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, 423, origin, CORS_METHODS ) } - 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 9d8be18..2f9b7e6 100644 --- a/src/app/api/machines/usb-policy/route.ts +++ b/src/app/api/machines/usb-policy/route.ts @@ -3,7 +3,6 @@ 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), @@ -55,15 +54,6 @@ 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) @@ -100,15 +90,6 @@ 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 2556a18..851a313 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -27,7 +27,6 @@ const COLLABORATOR_VISIBLE_TYPES: NotificationType[] = [ "security_email_verify", "security_email_change", "security_new_login", - "security_invite", ] export async function GET(_request: NextRequest) { @@ -70,8 +69,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 deleted file mode 100644 index e97572c..0000000 --- a/src/app/api/notifications/send/route.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7ebda15..0000000 --- a/src/app/api/profile/avatar/route.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * 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 1d51729..9677c57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,46 +3,67 @@ @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-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); -} + --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); +} :root { --radius: 0.75rem; @@ -78,6 +99,8 @@ --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 { @@ -232,3 +255,12 @@ 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 7b76fef..46ff46c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,24 +1,11 @@ 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", @@ -44,7 +31,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 6b1bf0b..f932c65 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -54,19 +54,12 @@ export function LoginPageClient() { return (
-
- -
- - Raven - - - Helpdesk - +
+ +
+ Sistema de chamados + Por Rever Tecnologia
- - Por Rever Tecnologia -
@@ -88,18 +81,8 @@ 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 4a322c3..3a6521f 100644 --- a/src/app/machines/handshake/route.ts +++ b/src/app/machines/handshake/route.ts @@ -9,7 +9,7 @@ const INACTIVE_TEMPLATE = ` - Dispositivo desativado + Dispositivo desativada - + - +
- +
- - - @@ -429,14 +333,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 @@ -448,14 +352,11 @@ 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 e entrar em contato em breve. +

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

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

- Chamado resolvido +

+ Chamado Resolvido

-

+

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

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

Resumo da resolução

+
+

RESUMO DA RESOLUÇÃO

${escapeHtml(data.resolutionSummary)}

` : "" } - ${divider()} - -
-

Como foi o atendimento?

-

Sua avaliação nos ajuda a melhorar!

+
+

Como foi o atendimento?

+

Sua avaliação nos ajuda a melhorar!

${ratingStars(rateUrl)}
- ${buttonSecondary("Ver detalhes", viewUrl)} + ${button("Ver Chamado", viewUrl)}
`, data @@ -533,17 +426,17 @@ const templates: Record string> = { const viewUrl = data.viewUrl as string const isForRequester = data.isForRequester as boolean - const title = isForRequester ? "Agente atribuído" : "Novo chamado atribuído" + const title = isForRequester ? "Agente Atribuído ao Chamado" : "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}

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

- Status atualizado +

+ Status Atualizado

-

+

O status do seu chamado foi alterado.

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

+

+

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

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

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

- Nova atualização +

+ Nova Atualização no Chamado

-

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

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

${ticketInfoCard({ @@ -626,18 +517,17 @@ 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)}

-
- ${buttonPrimary("Responder", viewUrl)} +
+ ${button("Ver Chamado", viewUrl)}
`, data @@ -650,25 +540,20 @@ 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.

- ${buttonPrimary("Redefinir senha", resetUrl)} + ${button("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 @@ -681,24 +566,18 @@ 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.

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

+

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

`, @@ -712,53 +591,37 @@ 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)}
-
- -
- ${buttonPrimary("Aceitar convite", inviteUrl)} +
+ + + + + + ${ + data.companyName + ? ` + + + + + ` + : "" + } +
Função${escapeHtml(data.roleName)}
Empresa${escapeHtml(data.companyName)}
-

+

+ ${button("Aceitar Convite", inviteUrl)} +
+ +

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

`, @@ -770,53 +633,31 @@ 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.

`, @@ -830,20 +671,11 @@ 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({ @@ -855,17 +687,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- ${escapeHtml(data.timeRemaining)} +

+

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

-

+

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

-
- ${buttonPrimary("Ver chamado", viewUrl)} +
+ ${button("Ver Chamado", viewUrl)}
`, data @@ -878,20 +710,11 @@ 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({ @@ -903,17 +726,17 @@ const templates: Record string> = { assigneeName: data.assigneeName as string, })} -
-

- ${escapeHtml(data.timeExceeded)} +

+

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

-

+

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

-
- ${buttonPrimary("Ver chamado", viewUrl)} +
+ ${button("Ver Chamado", viewUrl)}
`, data diff --git a/src/server/machines/inventory-export.ts b/src/server/machines/inventory-export.ts index 1cbdfd9..a0acc90 100644 --- a/src/server/machines/inventory-export.ts +++ b/src/server/machines/inventory-export.ts @@ -94,7 +94,6 @@ type MachineDerivedData = { collaborator: ReturnType remoteAccessCount: number fleetInfo: ReturnType - bootInfo: BootInfo customFieldByKey: Record customFieldById: Record } @@ -146,10 +145,10 @@ const COLUMN_VALUE_RESOLVERS: Record derived.collaborator?.name ?? null, collaboratorEmail: (_machine, derived) => derived.collaborator?.email ?? null, remoteAccessCount: (_machine, derived) => derived.remoteAccessCount, - lastBootTime: (_machine, derived) => - derived.bootInfo.lastBootTime ? formatDateTime(derived.bootInfo.lastBootTime) : null, - uptimeFormatted: (_machine, derived) => derived.bootInfo.uptimeFormatted, - bootCount30d: (_machine, derived) => derived.bootInfo.bootCount30d, + 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, managementMode: (machine) => describeManagementMode(machine.managementMode), usbPolicy: (machine) => describeUsbPolicy(machine.usbPolicy), usbPolicyStatus: (machine) => describeUsbPolicyStatus(machine.usbPolicyStatus), @@ -187,7 +186,6 @@ 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 ?? []) { @@ -207,7 +205,6 @@ function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData collaborator, remoteAccessCount, fleetInfo, - bootInfo, customFieldByKey, customFieldById, } @@ -1564,52 +1561,6 @@ 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 deleted file mode 100644 index a7d966b..0000000 --- a/src/server/machines/token-errors.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 2270f61..f965133 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 usam o endpoint publico para evitar falhas de DNS interno - CONVEX_INTERNAL_URL: "https://convex.esdrasrenan.com.br" + # URLs consumidas apenas pelo backend/SSR podem usar o hostname interno + CONVEX_INTERNAL_URL: "http://sistema_convex_backend:3210" # 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 'postgres18' existente na rede traefik_public) + # PostgreSQL connection string (usa o servico 'postgres' 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}@postgres18:5432/${POSTGRES_DB:-sistema_chamados}?connection_limit=10&pool_timeout=10" + DATABASE_URL: "postgresql://${POSTGRES_USER:-sistema}:${POSTGRES_PASSWORD}@postgres: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,21 +87,18 @@ services: # O novo container só entra em serviço APÓS passar no healthcheck start_period: 180s - # PostgreSQL: usando o servico 'postgres18' existente na rede traefik_public + # PostgreSQL: usando o servico 'postgres' 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:6690a911bced1e5e516eafc0409a7239fb6541bb + image: ghcr.io/get-convex/convex-backend:precompiled-2025-12-04-cc6af4c stop_grace_period: 10s stop_signal: SIGINT - command: - - --convex-http-proxy - - http://convex_proxy:8888 volumes: - convex_data:/convex/data environment: - - RUST_LOG=info,common::errors=error + - RUST_LOG=info - 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) @@ -139,17 +136,9 @@ 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 @@ -157,47 +146,6 @@ 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: @@ -225,6 +173,3 @@ 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 483b060..c24ea9e 100644 --- a/tests/automations-engine.test.ts +++ b/tests/automations-engine.test.ts @@ -287,18 +287,7 @@ describe("automations.runTicketAutomationsForEvent", () => { expect.objectContaining({ to: ["cliente@empresa.com"], subject: "Atualização do chamado #123", - 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", - }), + html: expect.any(String), }) ) }) diff --git a/tests/change-assignee-comment.test.ts b/tests/change-assignee-comment.test.ts new file mode 100644 index 0000000..d9e18f9 --- /dev/null +++ b/tests/change-assignee-comment.test.ts @@ -0,0 +1,29 @@ +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("