Compare commits
185 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d878f28e8 | ||
|
|
bc67dc01ef | ||
|
|
d125930cf6 | ||
|
|
6efbbd49e7 | ||
|
|
43017e6fef | ||
|
|
394fa9e2a3 | ||
|
|
a43151f84f | ||
|
|
0dd9f10984 | ||
|
|
2334e9a9ec | ||
|
|
badcb0f502 | ||
|
|
0a0f722bd8 | ||
|
|
9142446f06 | ||
|
|
9c6e724128 | ||
|
|
c030a3ac09 | ||
|
|
b7e2c4cc98 | ||
|
|
89f756e088 | ||
|
|
84117e6821 | ||
|
|
826b376dd3 | ||
|
|
d6531e2a4c | ||
|
|
649a270416 | ||
|
|
af37f0b30d | ||
|
|
026772e2f4 | ||
|
|
06c16ab2a9 | ||
|
|
73c14e2be3 | ||
|
|
f4a3b22aab | ||
|
|
1a0574e7f4 | ||
|
|
26b1a65ec4 | ||
|
|
d95af184be | ||
|
|
08ae1bd969 | ||
|
|
eb284a7f50 | ||
|
|
993bd3890a | ||
|
|
a9feea9b78 | ||
|
|
d38d5d39eb | ||
|
|
a5bab2cc33 | ||
|
|
70cba99424 | ||
|
|
c2802b1a4d | ||
|
|
1a75a69d4a | ||
|
|
ad5e26f211 | ||
|
|
dc740cd89a | ||
|
|
db23ea1901 | ||
|
|
f39bd46c2b | ||
|
|
d32b94c22d | ||
|
|
d6188fd384 | ||
|
|
ce52a4393b | ||
|
|
f9deb408dc | ||
|
|
73de65bbaf | ||
|
|
cfb72358bc | ||
|
|
3e63589055 | ||
|
|
f0c2bdc283 | ||
|
|
23fe67e7d3 | ||
|
|
ef2545221d | ||
|
|
a2fa5d046c | ||
|
|
a55f889689 | ||
|
|
4be622c838 | ||
|
|
5db31ba365 | ||
|
|
158fb32b8a | ||
|
|
8a237a820d | ||
|
|
14480df9f3 | ||
|
|
47ccdc51a7 | ||
|
|
33f0cc2e13 | ||
|
|
b3fcbcc682 | ||
|
|
ae4fd7f890 | ||
|
|
413749d999 | ||
|
|
0bfe4edc6c | ||
|
|
cd3305f1e3 | ||
|
|
0a36ed049f | ||
|
|
b8170d0225 | ||
|
|
bddce33217 | ||
|
|
6f9cdc8670 | ||
|
|
b5ff8034d2 | ||
|
|
034f6f47ff | ||
|
|
d12dcf9512 | ||
|
|
fffc3f553c | ||
|
|
30768ea090 | ||
|
|
3e923d5a53 | ||
|
|
ec7dc4ce12 | ||
|
|
498b9789b5 | ||
|
|
8546a1feb1 | ||
|
|
74c06ffa33 | ||
|
|
1e674d5006 | ||
|
|
965672e0fa | ||
|
|
385a8ee3df | ||
|
|
811ad0641a | ||
|
|
aa9c09c30e | ||
|
|
f617916fe7 | ||
|
|
4669be0107 | ||
|
|
028154a7bc | ||
|
|
67433ed5e4 | ||
|
|
3f9461a18f | ||
|
|
380b2e44e9 | ||
|
|
2bdc5ae882 | ||
|
|
52452f3023 | ||
|
|
9c258b43f1 | ||
|
|
9e385b664d | ||
|
|
6943a88e66 | ||
|
|
12a809805e | ||
|
|
f0a4b9b782 | ||
|
|
2c95834598 | ||
|
|
454c3d5c3b | ||
|
|
04226c16cc | ||
|
|
d067bda610 | ||
|
|
174b42eaab | ||
|
|
f748be1931 | ||
|
|
415d1c33f2 | ||
|
|
11dd44b54f | ||
|
|
b38d6689ae | ||
|
|
707306ddf8 | ||
|
|
acb2c35eeb | ||
|
|
c83cbd7e48 | ||
|
|
a48d98f6c4 | ||
|
|
9a65679ca4 | ||
|
|
1f88880dbd | ||
|
|
98a64f6166 | ||
|
|
e5bf783432 | ||
|
|
4a369ac783 | ||
|
|
aaa64e339c | ||
|
|
771e25798d | ||
|
|
8e5eccfd8e | ||
|
|
c0713875b1 | ||
|
|
db73e87cdc | ||
|
|
58cda4f6ea | ||
|
|
e844f16b7f | ||
|
|
6e8a6fe890 | ||
|
|
6430d33c7c | ||
|
|
0cdbc082ab | ||
|
|
2ba5f71580 | ||
|
|
5c5bf0385e | ||
|
|
d9d5b495a1 | ||
|
|
1986bf286a | ||
|
|
c51b08f127 | ||
|
|
a6af4aa580 | ||
|
|
92954b45c7 | ||
|
|
022e1f63ba | ||
|
|
23ea426c68 | ||
|
|
95c50d9d62 | ||
|
|
17fe70ad71 | ||
|
|
358e1256b9 | ||
|
|
bf8975df2d | ||
|
|
ab2bcdc755 | ||
|
|
d990450698 | ||
|
|
61c36dbb7c | ||
|
|
eedd446b36 | ||
|
|
7a3791117b | ||
|
|
a285e6f252 | ||
|
|
9d1908a5aa | ||
|
|
59e9298d61 | ||
|
|
f451ca2e3b | ||
|
|
129ae70930 | ||
|
|
10078c7aa7 | ||
|
|
f1833be1ea | ||
|
|
0f3ba07a5e | ||
|
|
98b23af4b2 | ||
|
|
3bfc5793f1 | ||
|
|
7f63120336 | ||
|
|
c776499403 | ||
|
|
8863fffc37 | ||
|
|
f6efc0d678 | ||
|
|
c0e0421369 | ||
|
|
c7b6d78ec2 | ||
|
|
4ad0dc5c1e | ||
|
|
424927573c | ||
|
|
5f0c9b68c3 | ||
|
|
3b6b9dfeac | ||
|
|
915ca6d8ff | ||
|
|
05bc1cb7b4 | ||
|
|
d97e692756 | ||
|
|
c36e18117b | ||
|
|
6b137434fe | ||
|
|
a3b46e5222 | ||
|
|
ca59b6ed92 | ||
|
|
973e3496e2 | ||
|
|
6c6d53034f | ||
|
|
86f818c6f3 | ||
|
|
29fbbfaa26 | ||
|
|
95ab1b5f0c | ||
|
|
bc5ba0c73a | ||
|
|
2c21daee79 | ||
|
|
4e2dd7f77e | ||
|
|
4bbd3fda24 | ||
|
|
b614fcd7dc | ||
|
|
ab7dfa81ca | ||
|
|
1bc08d3a5f | ||
|
|
300179279a | ||
|
|
2293a0275a | ||
|
|
c4664ab1c7 |
200 changed files with 21511 additions and 2949 deletions
|
|
@ -29,7 +29,63 @@
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(cargo check:*)",
|
"Bash(cargo check:*)",
|
||||||
"Bash(bun run:*)"
|
"Bash(bun run:*)",
|
||||||
|
"Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")",
|
||||||
|
"Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")",
|
||||||
|
"Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")",
|
||||||
|
"Bash(cmd /c \"echo %TEMP%\")",
|
||||||
|
"Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")",
|
||||||
|
"Bash(where:*)",
|
||||||
|
"Bash(ssh-keygen:*)",
|
||||||
|
"Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)",
|
||||||
|
"Bash(npx convex deploy:*)",
|
||||||
|
"Bash(dir \"%LOCALAPPDATA%\\Raven\")",
|
||||||
|
"Bash(dir \"%APPDATA%\\Raven\")",
|
||||||
|
"Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")",
|
||||||
|
"Bash(dir \"%APPDATA%\\com.raven.app\")",
|
||||||
|
"Bash(tasklist:*)",
|
||||||
|
"Bash(dir /s /b %LOCALAPPDATA%*raven*)",
|
||||||
|
"Bash(cmd /c \"tasklist | findstr /i raven\")",
|
||||||
|
"Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")",
|
||||||
|
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(bun scripts/test-all-emails.tsx:*)",
|
||||||
|
"Bash(bun scripts/send-test-react-email.tsx:*)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
|
"Bash(npx convex:*)",
|
||||||
|
"Bash(bun tsc:*)",
|
||||||
|
"Bash(scp:*)",
|
||||||
|
"Bash(docker run:*)",
|
||||||
|
"Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
|
||||||
|
"Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")",
|
||||||
|
"Bash(cmd /c \"docker --version && docker ps -a\")",
|
||||||
|
"Bash(powershell -Command \"docker --version\")",
|
||||||
|
"Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
|
||||||
|
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)",
|
||||||
|
"Bash(bunx prisma migrate:*)",
|
||||||
|
"Bash(bunx prisma db push:*)",
|
||||||
|
"Bash(bun run auth:seed:*)",
|
||||||
|
"Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)",
|
||||||
|
"Bash(bun tsx:*)",
|
||||||
|
"Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)",
|
||||||
|
"Bash(docker stop:*)",
|
||||||
|
"Bash(docker rm:*)",
|
||||||
|
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||||
|
"Bash(timeout 90 git push:*)",
|
||||||
|
"Bash(docker ps:*)",
|
||||||
|
"Bash(docker start:*)",
|
||||||
|
"Bash(docker inspect:*)",
|
||||||
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(timeout 90 git push)",
|
||||||
|
"Bash(bun test:*)",
|
||||||
|
"Bash(git restore:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\\src\\components\\ui\" /b)",
|
||||||
|
"Bash(timeout 120 bun:*)",
|
||||||
|
"Bash(bun run tauri:build:*)",
|
||||||
|
"Bash(git remote:*)",
|
||||||
|
"Bash(powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:/Projetos IA/sistema-de-chamados/scripts/test-windows-collection.ps1\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ REPORTS_CRON_SECRET=reports-cron-secret
|
||||||
# Diretório para arquivamento local de tickets (JSONL/backup)
|
# Diretório para arquivamento local de tickets (JSONL/backup)
|
||||||
ARCHIVE_DIR=./archives
|
ARCHIVE_DIR=./archives
|
||||||
|
|
||||||
# PostgreSQL database
|
# PostgreSQL database (versao 18)
|
||||||
# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
# Para desenvolvimento local, use Docker:
|
||||||
|
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||||
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||||
|
|
||||||
# SMTP Configuration (production values in docs/SMTP.md)
|
# SMTP Configuration (production values in docs/SMTP.md)
|
||||||
|
|
|
||||||
492
.forgejo/workflows/ci-cd-web-desktop.yml
Normal file
492
.forgejo/workflows/ci-cd-web-desktop.yml
Normal file
|
|
@ -0,0 +1,492 @@
|
||||||
|
name: CI/CD Web + Desktop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force_web_deploy:
|
||||||
|
description: 'Forcar deploy do Web (ignorar filtro)?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
force_convex_deploy:
|
||||||
|
description: 'Forcar deploy do Convex (ignorar filtro)?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_DIR: /srv/apps/sistema
|
||||||
|
VPS_UPDATES_DIR: /var/www/updates
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
name: Detect changes
|
||||||
|
runs-on: [ self-hosted, linux, vps ]
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
convex: ${{ steps.filter.outputs.convex }}
|
||||||
|
web: ${{ steps.filter.outputs.web }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
- name: Paths filter
|
||||||
|
id: filter
|
||||||
|
uses: https://github.com/dorny/paths-filter@v3
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
convex:
|
||||||
|
- 'convex/**'
|
||||||
|
web:
|
||||||
|
- 'src/**'
|
||||||
|
- 'public/**'
|
||||||
|
- 'prisma/**'
|
||||||
|
- 'next.config.ts'
|
||||||
|
- 'package.json'
|
||||||
|
- 'bun.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'middleware.ts'
|
||||||
|
- 'stack.yml'
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy (VPS Linux)
|
||||||
|
needs: changes
|
||||||
|
timeout-minutes: 30
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: [ self-hosted, linux, vps ]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Determine APP_DIR (fallback safe path)
|
||||||
|
id: appdir
|
||||||
|
run: |
|
||||||
|
TS=$(date +%s)
|
||||||
|
FALLBACK_DIR="$HOME/apps/web.build.$TS"
|
||||||
|
mkdir -p "$FALLBACK_DIR"
|
||||||
|
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
|
||||||
|
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: https://github.com/oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.4
|
||||||
|
|
||||||
|
- name: Sync workspace to APP_DIR (preserving local env)
|
||||||
|
run: |
|
||||||
|
mkdir -p "$EFFECTIVE_APP_DIR"
|
||||||
|
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
|
||||||
|
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
|
||||||
|
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
|
||||||
|
EXCLUDE_ENV=""
|
||||||
|
fi
|
||||||
|
rsync $RSYNC_FLAGS \
|
||||||
|
--filter='protect .next.old*' \
|
||||||
|
--exclude '.next.old*' \
|
||||||
|
--filter='protect node_modules' \
|
||||||
|
--filter='protect node_modules/**' \
|
||||||
|
--filter='protect .pnpm-store' \
|
||||||
|
--filter='protect .pnpm-store/**' \
|
||||||
|
--filter='protect .env' \
|
||||||
|
--filter='protect .env*' \
|
||||||
|
--filter='protect apps/desktop/.env*' \
|
||||||
|
--filter='protect convex/.env*' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'node_modules/**' \
|
||||||
|
--exclude '.pnpm-store' \
|
||||||
|
--exclude '.pnpm-store/**' \
|
||||||
|
$EXCLUDE_ENV \
|
||||||
|
./ "$EFFECTIVE_APP_DIR"/
|
||||||
|
|
||||||
|
- name: Acquire Convex admin key
|
||||||
|
id: key
|
||||||
|
run: |
|
||||||
|
echo "Waiting for Convex container..."
|
||||||
|
CID=""
|
||||||
|
for attempt in $(seq 1 12); do
|
||||||
|
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
|
||||||
|
if [ -n "$CID" ]; then
|
||||||
|
echo "Convex container ready (CID=$CID)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
|
||||||
|
if [ -n "$CID" ]; then
|
||||||
|
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
|
||||||
|
else
|
||||||
|
echo "No running convex container detected; attempting offline admin key extraction..."
|
||||||
|
VOLUME="sistema_convex_data"
|
||||||
|
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
|
||||||
|
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
|
||||||
|
else
|
||||||
|
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
|
||||||
|
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
|
||||||
|
if [ -z "$KEY" ]; then
|
||||||
|
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
|
||||||
|
docker service ps sistema_convex_backend || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Copy production .env if present
|
||||||
|
run: |
|
||||||
|
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
|
||||||
|
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
|
||||||
|
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
|
||||||
|
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Ensure Next.js cache directory exists and is writable
|
||||||
|
run: |
|
||||||
|
cd "$EFFECTIVE_APP_DIR"
|
||||||
|
mkdir -p .next/cache
|
||||||
|
chmod -R u+rwX .next || true
|
||||||
|
|
||||||
|
- name: Cache Next.js build cache (.next/cache)
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache
|
||||||
|
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('next.config.ts') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
|
||||||
|
${{ runner.os }}-nextjs-
|
||||||
|
|
||||||
|
- name: Lint check (fail fast before build)
|
||||||
|
run: |
|
||||||
|
cd "$EFFECTIVE_APP_DIR"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$EFFECTIVE_APP_DIR":/app \
|
||||||
|
-w /app \
|
||||||
|
sistema_web:node22-bun \
|
||||||
|
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint"
|
||||||
|
|
||||||
|
- name: Install and build (Next.js)
|
||||||
|
env:
|
||||||
|
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
||||||
|
run: |
|
||||||
|
cd "$EFFECTIVE_APP_DIR"
|
||||||
|
docker run --rm \
|
||||||
|
-e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \
|
||||||
|
-e NODE_OPTIONS="--max-old-space-size=4096" \
|
||||||
|
-v "$EFFECTIVE_APP_DIR":/app \
|
||||||
|
-w /app \
|
||||||
|
sistema_web:node22-bun \
|
||||||
|
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun"
|
||||||
|
|
||||||
|
- name: Fix Docker-created file permissions
|
||||||
|
run: |
|
||||||
|
# Docker cria arquivos como root - corrigir para o usuario runner (UID 1000)
|
||||||
|
docker run --rm -v "$EFFECTIVE_APP_DIR":/target alpine:3 \
|
||||||
|
chown -R 1000:1000 /target
|
||||||
|
echo "Permissoes do build corrigidas"
|
||||||
|
|
||||||
|
- name: Atualizar symlink do APP_DIR estavel (deploy atomico)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$HOME/apps"
|
||||||
|
STABLE_LINK="$ROOT/sistema.current"
|
||||||
|
|
||||||
|
mkdir -p "$ROOT"
|
||||||
|
|
||||||
|
# Sanidade: se esses arquivos nao existirem, o container vai falhar no boot.
|
||||||
|
test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
|
||||||
|
test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
|
||||||
|
test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; }
|
||||||
|
test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; }
|
||||||
|
|
||||||
|
PREV=""
|
||||||
|
if [ -L "$STABLE_LINK" ]; then
|
||||||
|
PREV="$(readlink -f "$STABLE_LINK" || true)"
|
||||||
|
fi
|
||||||
|
echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK"
|
||||||
|
|
||||||
|
# Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta).
|
||||||
|
if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then
|
||||||
|
ln -sfn "$STABLE_LINK" "$ROOT/sistema"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")"
|
||||||
|
|
||||||
|
- name: Swarm deploy (stack.yml)
|
||||||
|
run: |
|
||||||
|
APP_DIR_STABLE="$HOME/apps/sistema.current"
|
||||||
|
if [ ! -d "$APP_DIR_STABLE" ]; then
|
||||||
|
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
|
||||||
|
fi
|
||||||
|
cd "$APP_DIR_STABLE"
|
||||||
|
set -o allexport
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "Loading .env from $APP_DIR_STABLE"
|
||||||
|
. ./.env
|
||||||
|
else
|
||||||
|
echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!"
|
||||||
|
fi
|
||||||
|
set +o allexport
|
||||||
|
echo "Using APP_DIR (stable)=$APP_DIR_STABLE"
|
||||||
|
echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-<not set>}"
|
||||||
|
echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-<not set>}"
|
||||||
|
APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
|
||||||
|
|
||||||
|
- name: Wait for services to be healthy
|
||||||
|
run: |
|
||||||
|
echo "Aguardando servicos ficarem saudaveis..."
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
WEB_STATUS=$(docker service ls --filter "name=sistema_web" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
|
||||||
|
CONVEX_STATUS=$(docker service ls --filter "name=sistema_convex_backend" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
|
||||||
|
echo "Tentativa $i/18: web=$WEB_STATUS convex=$CONVEX_STATUS"
|
||||||
|
if echo "$WEB_STATUS" | grep -q "2/2" && echo "$CONVEX_STATUS" | grep -q "1/1"; then
|
||||||
|
echo "Todos os servicos estao saudaveis!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "ERRO: Timeout aguardando servicos. Status atual:"
|
||||||
|
docker service ls --filter "label=com.docker.stack.namespace=sistema" || true
|
||||||
|
docker service ps sistema_web --no-trunc || true
|
||||||
|
docker service logs sistema_web --since 5m --raw 2>/dev/null | tail -n 200 || true
|
||||||
|
|
||||||
|
if [ -n "${PREV_APP_DIR:-}" ]; then
|
||||||
|
echo "Rollback: revertendo APP_DIR estavel para: $PREV_APP_DIR"
|
||||||
|
ln -sfn "$PREV_APP_DIR" "$HOME/apps/sistema.current"
|
||||||
|
cd "$HOME/apps/sistema.current"
|
||||||
|
set -o allexport
|
||||||
|
if [ -f .env ]; then
|
||||||
|
. ./.env
|
||||||
|
fi
|
||||||
|
set +o allexport
|
||||||
|
APP_DIR="$HOME/apps/sistema.current" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Cleanup old build workdirs (keep last 2)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ROOT="$HOME/apps"
|
||||||
|
KEEP=2
|
||||||
|
PATTERN='web.build.*'
|
||||||
|
ACTIVE="$(readlink -f "$HOME/apps/sistema.current" 2>/dev/null || true)"
|
||||||
|
echo "Scanning $ROOT for old $PATTERN dirs"
|
||||||
|
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
|
||||||
|
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
|
||||||
|
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
|
||||||
|
[ -z "$dir" ] && continue
|
||||||
|
if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then
|
||||||
|
echo "Skipping active dir (in use by APP_DIR): $dir"; continue
|
||||||
|
fi
|
||||||
|
echo "Removing $dir"
|
||||||
|
chmod -R u+rwX "$dir" 2>/dev/null || true
|
||||||
|
rm -rf "$dir" || {
|
||||||
|
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
|
||||||
|
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
|
||||||
|
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
done
|
||||||
|
echo "Disk usage (top 10 under $ROOT):"
|
||||||
|
du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true
|
||||||
|
|
||||||
|
convex_deploy:
|
||||||
|
name: Deploy Convex functions
|
||||||
|
needs: changes
|
||||||
|
timeout-minutes: 20
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }}
|
||||||
|
runs-on: [ self-hosted, linux, vps ]
|
||||||
|
env:
|
||||||
|
APP_DIR: /srv/apps/sistema
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Determine APP_DIR (fallback safe path)
|
||||||
|
id: appdir
|
||||||
|
run: |
|
||||||
|
TS=$(date +%s)
|
||||||
|
FALLBACK_DIR="$HOME/apps/convex.build.$TS"
|
||||||
|
mkdir -p "$FALLBACK_DIR"
|
||||||
|
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
|
||||||
|
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Sync workspace to APP_DIR (preserving local env)
|
||||||
|
run: |
|
||||||
|
mkdir -p "$EFFECTIVE_APP_DIR"
|
||||||
|
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
|
||||||
|
rsync $RSYNC_FLAGS \
|
||||||
|
--filter='protect .next.old*' \
|
||||||
|
--exclude '.next.old*' \
|
||||||
|
--exclude '.env*' \
|
||||||
|
--exclude 'apps/desktop/.env*' \
|
||||||
|
--exclude 'convex/.env*' \
|
||||||
|
--filter='protect node_modules' \
|
||||||
|
--filter='protect node_modules/**' \
|
||||||
|
--filter='protect .pnpm-store' \
|
||||||
|
--filter='protect .pnpm-store/**' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'node_modules/**' \
|
||||||
|
--exclude '.pnpm-store' \
|
||||||
|
--exclude '.pnpm-store/**' \
|
||||||
|
./ "$EFFECTIVE_APP_DIR"/
|
||||||
|
|
||||||
|
- name: Acquire Convex admin key
|
||||||
|
id: key
|
||||||
|
run: |
|
||||||
|
echo "Waiting for Convex container..."
|
||||||
|
CID=""
|
||||||
|
for attempt in $(seq 1 12); do
|
||||||
|
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
|
||||||
|
if [ -n "$CID" ]; then
|
||||||
|
echo "Convex container ready (CID=$CID)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
|
||||||
|
if [ -n "$CID" ]; then
|
||||||
|
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
|
||||||
|
else
|
||||||
|
echo "No running convex container detected; attempting offline admin key extraction..."
|
||||||
|
VOLUME="sistema_convex_data"
|
||||||
|
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
|
||||||
|
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
|
||||||
|
else
|
||||||
|
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
|
||||||
|
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
|
||||||
|
if [ -z "$KEY" ]; then
|
||||||
|
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
|
||||||
|
docker service ps sistema_convex_backend || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bring convex.json from live app if present
|
||||||
|
run: |
|
||||||
|
if [ -f "$APP_DIR/convex.json" ]; then
|
||||||
|
echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json"
|
||||||
|
cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json"
|
||||||
|
else
|
||||||
|
echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set Convex env vars (self-hosted)
|
||||||
|
env:
|
||||||
|
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
|
||||||
|
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
|
||||||
|
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
|
||||||
|
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
|
||||||
|
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker run --rm -i \
|
||||||
|
-v "$EFFECTIVE_APP_DIR":/app \
|
||||||
|
-w /app \
|
||||||
|
-e CONVEX_SELF_HOSTED_URL \
|
||||||
|
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
|
||||||
|
-e MACHINE_PROVISIONING_SECRET \
|
||||||
|
-e MACHINE_TOKEN_TTL_MS \
|
||||||
|
-e FLEET_SYNC_SECRET \
|
||||||
|
-e CONVEX_TMPDIR=/app/.convex-tmp \
|
||||||
|
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; \
|
||||||
|
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
|
||||||
|
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
|
||||||
|
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
|
||||||
|
bunx convex env list"
|
||||||
|
|
||||||
|
- name: Prepare Convex deploy workspace
|
||||||
|
run: |
|
||||||
|
cd "$EFFECTIVE_APP_DIR"
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
|
||||||
|
mv -f .env .env.bak
|
||||||
|
fi
|
||||||
|
mkdir -p .convex-tmp
|
||||||
|
|
||||||
|
- name: Deploy functions to Convex self-hosted
|
||||||
|
env:
|
||||||
|
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
|
||||||
|
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
|
||||||
|
run: |
|
||||||
|
docker run --rm -i \
|
||||||
|
-v "$EFFECTIVE_APP_DIR":/app \
|
||||||
|
-w /app \
|
||||||
|
-e CI=true \
|
||||||
|
-e CONVEX_SELF_HOSTED_URL \
|
||||||
|
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
|
||||||
|
-e CONVEX_TMPDIR=/app/.convex-tmp \
|
||||||
|
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; bunx convex deploy"
|
||||||
|
|
||||||
|
- name: Cleanup old convex build workdirs (keep last 2)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ROOT="$HOME/apps"
|
||||||
|
KEEP=2
|
||||||
|
PATTERN='convex.build.*'
|
||||||
|
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
|
||||||
|
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
|
||||||
|
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
|
||||||
|
[ -z "$dir" ] && continue
|
||||||
|
echo "Removing $dir"
|
||||||
|
chmod -R u+rwX "$dir" 2>/dev/null || true
|
||||||
|
rm -rf "$dir" || {
|
||||||
|
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
|
||||||
|
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
|
||||||
|
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# NOTA: Job comentado porque nao ha runner Windows configurado.
|
||||||
|
# Descomentar quando configurar um runner com labels: [self-hosted, windows, desktop]
|
||||||
|
#
|
||||||
|
# desktop_release:
|
||||||
|
# name: Desktop Release (Windows)
|
||||||
|
# timeout-minutes: 30
|
||||||
|
# if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
# runs-on: [ self-hosted, windows, desktop ]
|
||||||
|
# defaults:
|
||||||
|
# run:
|
||||||
|
# working-directory: apps/desktop
|
||||||
|
# steps:
|
||||||
|
# - name: Checkout
|
||||||
|
# uses: https://github.com/actions/checkout@v4
|
||||||
|
#
|
||||||
|
# - name: Setup pnpm
|
||||||
|
# uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
# with:
|
||||||
|
# version: 10.20.0
|
||||||
|
#
|
||||||
|
# - name: Setup Node.js
|
||||||
|
# uses: https://github.com/actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: 20
|
||||||
|
#
|
||||||
|
# - name: Install deps (desktop)
|
||||||
|
# run: pnpm install --frozen-lockfile
|
||||||
|
#
|
||||||
|
# - name: Build with Tauri
|
||||||
|
# uses: https://github.com/tauri-apps/tauri-action@v0
|
||||||
|
# env:
|
||||||
|
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
# TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
# with:
|
||||||
|
# projectPath: apps/desktop
|
||||||
|
#
|
||||||
|
# - name: Upload bundles to VPS
|
||||||
|
# run: |
|
||||||
|
# # Upload via SCP (configurar chave SSH no runner Windows)
|
||||||
|
# # scp -r src-tauri/target/release/bundle/* user@vps:/var/www/updates/
|
||||||
|
# echo "TODO: Configurar upload para VPS"
|
||||||
54
.forgejo/workflows/quality-checks.yml
Normal file
54
.forgejo/workflows/quality-checks.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: Quality Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-test-build:
|
||||||
|
name: Lint, Test and Build
|
||||||
|
runs-on: [ self-hosted, linux, vps ]
|
||||||
|
env:
|
||||||
|
BETTER_AUTH_SECRET: test-secret
|
||||||
|
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||||
|
BETTER_AUTH_URL: http://localhost:3000
|
||||||
|
NEXT_PUBLIC_CONVEX_URL: http://localhost:3210
|
||||||
|
DATABASE_URL: file:./prisma/db.dev.sqlite
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: https://github.com/oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache Next.js build cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ github.workspace }}/.next/cache
|
||||||
|
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
env:
|
||||||
|
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
||||||
|
run: bun run prisma:generate
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build:bun
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -70,3 +70,4 @@ rustdesk/
|
||||||
|
|
||||||
# Prisma generated files
|
# Prisma generated files
|
||||||
src/generated/
|
src/generated/
|
||||||
|
apps/desktop/service/target/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Runtime image with Node 22 + Bun 1.3.2 and build toolchain preinstalled
|
# Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled
|
||||||
FROM node:22-bullseye-slim
|
FROM node:22-bullseye-slim
|
||||||
|
|
||||||
ENV BUN_INSTALL=/root/.bun
|
ENV BUN_INSTALL=/root/.bun
|
||||||
|
|
@ -17,9 +17,9 @@ RUN apt-get update -y \
|
||||||
git \
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Bun 1.3.2
|
# Install Bun 1.3.4
|
||||||
RUN curl -fsSL https://bun.sh/install \
|
RUN curl -fsSL https://bun.sh/install \
|
||||||
| bash -s -- bun-v1.3.2 \
|
| bash -s -- bun-v1.3.4 \
|
||||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx
|
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx
|
||||||
|
|
||||||
|
|
|
||||||
65
agents.md
65
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`.
|
- 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.
|
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
|
||||||
|
|
||||||
## Stack atual (06/11/2025)
|
## Stack atual (18/12/2025)
|
||||||
- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback).
|
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
|
||||||
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
|
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
|
||||||
- **React / React DOM**: `19.2.0`.
|
- **React / React DOM**: `19.2.1`.
|
||||||
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
|
- **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.
|
- **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.
|
- **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_URL=http://localhost:3000
|
||||||
BETTER_AUTH_SECRET=dev-only-long-random-string
|
BETTER_AUTH_SECRET=dev-only-long-random-string
|
||||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||||
DATABASE_URL=file:./prisma/db.dev.sqlite
|
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||||
```
|
```
|
||||||
3. `bun run auth:seed`
|
3. `bun run auth:seed`
|
||||||
4. (Opcional) `bun run queues:ensure`
|
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.
|
7. Acesse `http://localhost:3000` e valide login com os usuários padrão.
|
||||||
|
|
||||||
### Banco de dados
|
### Banco de dados
|
||||||
- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`).
|
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
|
||||||
- Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`).
|
- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
|
||||||
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
|
- 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
|
### Verificações antes de PR/deploy
|
||||||
|
|
@ -104,12 +104,12 @@ bun run build:bun
|
||||||
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
|
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
|
||||||
docker service update --force sistema_web
|
docker service update --force sistema_web
|
||||||
```
|
```
|
||||||
- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`:
|
- Resolver `P3009` (migration falhou) no PostgreSQL ativo:
|
||||||
```bash
|
```bash
|
||||||
docker service scale sistema_web=0
|
docker service scale sistema_web=0
|
||||||
docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \
|
docker run --rm -it --network traefik_public \
|
||||||
|
--env-file /home/renan/apps/sistema.current/.env \
|
||||||
-v /home/renan/apps/sistema.current:/app \
|
-v /home/renan/apps/sistema.current:/app \
|
||||||
-v sistema_sistema_db:/app/data -w /app \
|
|
||||||
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
|
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
|
||||||
docker service scale sistema_web=1
|
docker service scale sistema_web=1
|
||||||
```
|
```
|
||||||
|
|
@ -164,8 +164,51 @@ bun run build:bun
|
||||||
- **Docs complementares**:
|
- **Docs complementares**:
|
||||||
- `docs/DEV.md` — guia diário atualizado.
|
- `docs/DEV.md` — guia diário atualizado.
|
||||||
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
||||||
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
|
- `docs/OPERATIONS.md` — runbook do Swarm.
|
||||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
||||||
|
|
||||||
|
## Regras de Codigo
|
||||||
|
|
||||||
|
### Tooltips Nativos do Navegador
|
||||||
|
|
||||||
|
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
|
||||||
|
|
||||||
|
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ERRADO - causa tooltip nativo do navegador
|
||||||
|
<button title="Remover item">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// CORRETO - sem tooltip nativo
|
||||||
|
<button>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Remover item</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Excecoes:**
|
||||||
|
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
|
||||||
|
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button aria-label="Remover item">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
|
_Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "node ./scripts/tauri-with-stub.mjs",
|
"tauri": "node ./scripts/tauri-with-stub.mjs",
|
||||||
"gen:icon": "node ./scripts/build-icon.mjs"
|
"gen:icon": "node ./scripts/build-icon.mjs",
|
||||||
|
"build:service": "cd service && cargo build --release",
|
||||||
|
"build:all": "bun run build:service && bun run tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"convex": "^1.31.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|
|
||||||
BIN
apps/desktop/public/logo-raven.png
Normal file
BIN
apps/desktop/public/logo-raven.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
1931
apps/desktop/service/Cargo.lock
generated
Normal file
1931
apps/desktop/service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
70
apps/desktop/service/Cargo.toml
Normal file
70
apps/desktop/service/Cargo.toml
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
[package]
|
||||||
|
name = "raven-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop"
|
||||||
|
authors = ["Esdras Renan"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "raven-service"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Windows Service
|
||||||
|
windows-service = "0.7"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] }
|
||||||
|
|
||||||
|
# IPC via Named Pipes
|
||||||
|
interprocess = { version = "2", features = ["tokio"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Windows Registry
|
||||||
|
winreg = "0.55"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# HTTP client (para RustDesk)
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
|
||||||
|
|
||||||
|
# Date/time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Crypto (para RustDesk ID)
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# UUID para request IDs
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
# Parking lot para locks
|
||||||
|
parking_lot = "0.12"
|
||||||
|
|
||||||
|
# Once cell para singletons
|
||||||
|
once_cell = "1.19"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_System_Services",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_Pipes",
|
||||||
|
"Win32_System_IO",
|
||||||
|
"Win32_System_SystemServices",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
290
apps/desktop/service/src/ipc.rs
Normal file
290
apps/desktop/service/src/ipc.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
//! Modulo IPC - Servidor de Named Pipes
|
||||||
|
//!
|
||||||
|
//! Implementa comunicacao entre o Raven UI e o Raven Service
|
||||||
|
//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado.
|
||||||
|
|
||||||
|
use crate::{rustdesk, usb_policy};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum IpcError {
|
||||||
|
#[error("Erro de IO: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Erro de serializacao: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requisicao JSON-RPC simplificada
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Request {
|
||||||
|
pub id: String,
|
||||||
|
pub method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub params: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resposta JSON-RPC simplificada
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Response {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<ErrorResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub code: i32,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
pub fn success(id: String, result: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(id: String, code: i32, message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
result: None,
|
||||||
|
error: Some(ErrorResponse { code, message }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inicia o servidor de Named Pipes
|
||||||
|
pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> {
|
||||||
|
info!("Iniciando servidor IPC em: {}", pipe_name);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match accept_connection(pipe_name).await {
|
||||||
|
Ok(()) => {
|
||||||
|
debug!("Conexao processada com sucesso");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Erro ao processar conexao: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aceita uma conexao e processa requisicoes
|
||||||
|
async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> {
|
||||||
|
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||||
|
use windows::Win32::Security::{
|
||||||
|
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
|
||||||
|
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
|
||||||
|
};
|
||||||
|
use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
|
||||||
|
use windows::Win32::System::Pipes::{
|
||||||
|
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe,
|
||||||
|
PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION;
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
|
||||||
|
// Cria o named pipe com seguranca que permite acesso a todos os usuarios
|
||||||
|
let pipe_name_wide: Vec<u16> = pipe_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
|
||||||
|
// Cria security descriptor com DACL nulo (permite acesso a todos)
|
||||||
|
let mut sd = SECURITY_DESCRIPTOR::default();
|
||||||
|
unsafe {
|
||||||
|
let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
|
||||||
|
let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION);
|
||||||
|
// DACL nulo = acesso irrestrito
|
||||||
|
let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sa = SECURITY_ATTRIBUTES {
|
||||||
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
|
||||||
|
bInheritHandle: false.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pipe_handle = unsafe {
|
||||||
|
CreateNamedPipeW(
|
||||||
|
PCWSTR::from_raw(pipe_name_wide.as_ptr()),
|
||||||
|
PIPE_ACCESS_DUPLEX,
|
||||||
|
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
|
||||||
|
PIPE_UNLIMITED_INSTANCES,
|
||||||
|
4096, // out buffer
|
||||||
|
4096, // in buffer
|
||||||
|
0, // default timeout
|
||||||
|
Some(&sa), // seguranca permissiva
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verifica se o handle e valido
|
||||||
|
if pipe_handle == INVALID_HANDLE_VALUE {
|
||||||
|
return Err(IpcError::Io(std::io::Error::last_os_error()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aguarda conexao de um cliente
|
||||||
|
info!("Aguardando conexao de cliente...");
|
||||||
|
let connect_result = unsafe {
|
||||||
|
ConnectNamedPipe(pipe_handle, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = connect_result {
|
||||||
|
// ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado
|
||||||
|
// o que e aceitavel
|
||||||
|
let error_code = e.code().0 as u32;
|
||||||
|
if error_code != 535 {
|
||||||
|
warn!("Erro ao aguardar conexao: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Cliente conectado");
|
||||||
|
|
||||||
|
// Processa requisicoes do cliente
|
||||||
|
let result = process_client(pipe_handle);
|
||||||
|
|
||||||
|
// Desconecta o cliente
|
||||||
|
unsafe {
|
||||||
|
let _ = DisconnectNamedPipe(pipe_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processa requisicoes de um cliente conectado
|
||||||
|
fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> {
|
||||||
|
use std::os::windows::io::{FromRawHandle, RawHandle};
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
// Cria File handle a partir do pipe
|
||||||
|
let raw_handle = pipe_handle.0 as RawHandle;
|
||||||
|
let file = unsafe { File::from_raw_handle(raw_handle) };
|
||||||
|
|
||||||
|
let reader = BufReader::new(file.try_clone()?);
|
||||||
|
let mut writer = file;
|
||||||
|
|
||||||
|
// Le linhas (cada linha e uma requisicao JSON)
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
|
info!("Cliente desconectou");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Requisicao recebida: {}", line);
|
||||||
|
|
||||||
|
// Parse da requisicao
|
||||||
|
let response = match serde_json::from_str::<Request>(&line) {
|
||||||
|
Ok(request) => handle_request(request),
|
||||||
|
Err(e) => Response::error(
|
||||||
|
"unknown".to_string(),
|
||||||
|
-32700,
|
||||||
|
format!("Parse error: {}", e),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serializa e envia resposta
|
||||||
|
let response_json = serde_json::to_string(&response)?;
|
||||||
|
debug!("Resposta: {}", response_json);
|
||||||
|
|
||||||
|
writeln!(writer, "{}", response_json)?;
|
||||||
|
writer.flush()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele
|
||||||
|
std::mem::forget(writer);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processa uma requisicao e retorna a resposta
|
||||||
|
fn handle_request(request: Request) -> Response {
|
||||||
|
info!("Processando metodo: {}", request.method);
|
||||||
|
|
||||||
|
match request.method.as_str() {
|
||||||
|
"health_check" => handle_health_check(request.id),
|
||||||
|
"apply_usb_policy" => handle_apply_usb_policy(request.id, request.params),
|
||||||
|
"get_usb_policy" => handle_get_usb_policy(request.id),
|
||||||
|
"provision_rustdesk" => handle_provision_rustdesk(request.id, request.params),
|
||||||
|
"get_rustdesk_status" => handle_get_rustdesk_status(request.id),
|
||||||
|
_ => Response::error(
|
||||||
|
request.id,
|
||||||
|
-32601,
|
||||||
|
format!("Metodo nao encontrado: {}", request.method),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Handlers de Requisicoes
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn handle_health_check(id: String) -> Response {
|
||||||
|
Response::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "RavenService",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"timestamp": chrono::Utc::now().timestamp_millis()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response {
|
||||||
|
let policy = match params.get("policy").and_then(|p| p.as_str()) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match usb_policy::apply_policy(policy) {
|
||||||
|
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
|
||||||
|
Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get_usb_policy(id: String) -> Response {
|
||||||
|
match usb_policy::get_current_policy() {
|
||||||
|
Ok(policy) => Response::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({
|
||||||
|
"policy": policy
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response {
|
||||||
|
let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from);
|
||||||
|
let password = params.get("password").and_then(|p| p.as_str()).map(String::from);
|
||||||
|
let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
|
||||||
|
match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) {
|
||||||
|
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
|
||||||
|
Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get_rustdesk_status(id: String) -> Response {
|
||||||
|
match rustdesk::get_status() {
|
||||||
|
Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()),
|
||||||
|
Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
268
apps/desktop/service/src/main.rs
Normal file
268
apps/desktop/service/src/main.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
//! Raven Service - Servico Windows para operacoes privilegiadas
|
||||||
|
//!
|
||||||
|
//! Este servico roda como LocalSystem e executa operacoes que requerem
|
||||||
|
//! privilegios de administrador, como:
|
||||||
|
//! - Aplicar politicas de USB
|
||||||
|
//! - Provisionar e configurar RustDesk
|
||||||
|
//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE
|
||||||
|
//!
|
||||||
|
//! O app Raven UI comunica com este servico via Named Pipes.
|
||||||
|
|
||||||
|
mod ipc;
|
||||||
|
mod rustdesk;
|
||||||
|
mod usb_policy;
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{error, info};
|
||||||
|
use windows_service::{
|
||||||
|
define_windows_service,
|
||||||
|
service::{
|
||||||
|
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
|
||||||
|
ServiceType,
|
||||||
|
},
|
||||||
|
service_control_handler::{self, ServiceControlHandlerResult},
|
||||||
|
service_dispatcher,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVICE_NAME: &str = "RavenService";
|
||||||
|
const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service";
|
||||||
|
const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)";
|
||||||
|
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
|
||||||
|
|
||||||
|
define_windows_service!(ffi_service_main, service_main);
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Configura logging
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
// Verifica argumentos de linha de comando
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
match args[1].as_str() {
|
||||||
|
"install" => {
|
||||||
|
install_service()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
"uninstall" => {
|
||||||
|
uninstall_service()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
"run" => {
|
||||||
|
// Modo de teste: roda sem registrar como servico
|
||||||
|
info!("Executando em modo de teste (nao como servico)");
|
||||||
|
run_standalone()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicia como servico Windows
|
||||||
|
info!("Iniciando Raven Service...");
|
||||||
|
service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
// Tenta criar diretorio de logs
|
||||||
|
let log_dir = std::env::var("PROGRAMDATA")
|
||||||
|
.map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs"))
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs"));
|
||||||
|
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
|
||||||
|
// Arquivo de log
|
||||||
|
let log_file = log_dir.join("service.log");
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_file)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
|
if let Some(file) = file {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(fmt::layer().with_writer(file).with_ansi(false))
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(fmt::layer())
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_main(arguments: Vec<OsString>) {
|
||||||
|
if let Err(e) = run_service(arguments) {
|
||||||
|
error!("Erro ao executar servico: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_service(_arguments: Vec<OsString>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
info!("Servico iniciando...");
|
||||||
|
|
||||||
|
// Canal para shutdown
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
|
||||||
|
|
||||||
|
// Registra handler de controle do servico
|
||||||
|
let shutdown_tx_clone = shutdown_tx.clone();
|
||||||
|
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
|
||||||
|
match control {
|
||||||
|
ServiceControl::Stop | ServiceControl::Shutdown => {
|
||||||
|
info!("Recebido comando de parada");
|
||||||
|
if let Ok(mut guard) = shutdown_tx_clone.lock() {
|
||||||
|
if let Some(tx) = guard.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServiceControlHandlerResult::NoError
|
||||||
|
}
|
||||||
|
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||||
|
_ => ServiceControlHandlerResult::NotImplemented,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Atualiza status para Running
|
||||||
|
status_handle.set_service_status(ServiceStatus {
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
current_state: ServiceState::Running,
|
||||||
|
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||||
|
exit_code: ServiceExitCode::Win32(0),
|
||||||
|
checkpoint: 0,
|
||||||
|
wait_hint: Duration::default(),
|
||||||
|
process_id: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Servico em execucao, aguardando conexoes...");
|
||||||
|
|
||||||
|
// Cria runtime Tokio
|
||||||
|
let runtime = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
|
// Executa servidor IPC
|
||||||
|
runtime.block_on(async {
|
||||||
|
tokio::select! {
|
||||||
|
result = ipc::run_server(PIPE_NAME) => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
error!("Erro no servidor IPC: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = async {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
} => {
|
||||||
|
info!("Shutdown solicitado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualiza status para Stopped
|
||||||
|
status_handle.set_service_status(ServiceStatus {
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
current_state: ServiceState::Stopped,
|
||||||
|
controls_accepted: ServiceControlAccept::empty(),
|
||||||
|
exit_code: ServiceExitCode::Win32(0),
|
||||||
|
checkpoint: 0,
|
||||||
|
wait_hint: Duration::default(),
|
||||||
|
process_id: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Servico parado");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_standalone() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let runtime = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
|
runtime.block_on(async {
|
||||||
|
info!("Servidor IPC iniciando em modo standalone...");
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = ipc::run_server(PIPE_NAME) => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
error!("Erro no servidor IPC: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
info!("Ctrl+C recebido, encerrando...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_service() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use windows_service::{
|
||||||
|
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
|
||||||
|
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Instalando servico...");
|
||||||
|
|
||||||
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?;
|
||||||
|
|
||||||
|
let exe_path = std::env::current_exe()?;
|
||||||
|
|
||||||
|
let service_info = ServiceInfo {
|
||||||
|
name: OsString::from(SERVICE_NAME),
|
||||||
|
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
start_type: ServiceStartType::AutoStart,
|
||||||
|
error_control: ServiceErrorControl::Normal,
|
||||||
|
executable_path: exe_path,
|
||||||
|
launch_arguments: vec![],
|
||||||
|
dependencies: vec![],
|
||||||
|
account_name: None, // LocalSystem
|
||||||
|
account_password: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
|
||||||
|
|
||||||
|
// Define descricao
|
||||||
|
service.set_description(SERVICE_DESCRIPTION)?;
|
||||||
|
|
||||||
|
info!("Servico instalado com sucesso: {}", SERVICE_NAME);
|
||||||
|
println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME);
|
||||||
|
println!("Para iniciar: sc start {}", SERVICE_NAME);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use windows_service::{
|
||||||
|
service::ServiceAccess,
|
||||||
|
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Desinstalando servico...");
|
||||||
|
|
||||||
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||||
|
|
||||||
|
let service = manager.open_service(
|
||||||
|
SERVICE_NAME,
|
||||||
|
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Tenta parar o servico primeiro
|
||||||
|
let status = service.query_status()?;
|
||||||
|
if status.current_state != ServiceState::Stopped {
|
||||||
|
info!("Parando servico...");
|
||||||
|
let _ = service.stop();
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove o servico
|
||||||
|
service.delete()?;
|
||||||
|
|
||||||
|
info!("Servico desinstalado com sucesso");
|
||||||
|
println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
846
apps/desktop/service/src/rustdesk.rs
Normal file
846
apps/desktop/service/src/rustdesk.rs
Normal file
|
|
@ -0,0 +1,846 @@
|
||||||
|
//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk
|
||||||
|
//!
|
||||||
|
//! Gerencia a instalacao, configuracao e provisionamento do RustDesk.
|
||||||
|
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest";
|
||||||
|
const USER_AGENT: &str = "RavenService/1.0";
|
||||||
|
const SERVER_HOST: &str = "rust.rever.com.br";
|
||||||
|
const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI=";
|
||||||
|
const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI<b*34Vmx_8P";
|
||||||
|
const SERVICE_NAME: &str = "RustDesk";
|
||||||
|
const CACHE_DIR_NAME: &str = "Rever\\RustDeskCache";
|
||||||
|
const LOCAL_SERVICE_CONFIG: &str = r"C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config";
|
||||||
|
const LOCAL_SYSTEM_CONFIG: &str = r"C:\Windows\System32\config\systemprofile\AppData\Roaming\RustDesk\config";
|
||||||
|
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
|
||||||
|
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
static PROVISION_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RustdeskError {
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error("Release asset nao encontrado para Windows x86_64")]
|
||||||
|
AssetMissing,
|
||||||
|
|
||||||
|
#[error("Falha ao executar comando {command}: status {status:?}")]
|
||||||
|
CommandFailed { command: String, status: Option<i32> },
|
||||||
|
|
||||||
|
#[error("Falha ao detectar ID do RustDesk")]
|
||||||
|
MissingId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RustdeskResult {
|
||||||
|
pub id: String,
|
||||||
|
pub password: String,
|
||||||
|
pub installed_version: Option<String>,
|
||||||
|
pub updated: bool,
|
||||||
|
pub last_provisioned_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RustdeskStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReleaseAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReleaseResponse {
|
||||||
|
tag_name: String,
|
||||||
|
assets: Vec<ReleaseAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provisiona o RustDesk
|
||||||
|
pub fn ensure_rustdesk(
|
||||||
|
config_string: Option<&str>,
|
||||||
|
password_override: Option<&str>,
|
||||||
|
machine_id: Option<&str>,
|
||||||
|
) -> Result<RustdeskResult, RustdeskError> {
|
||||||
|
let _guard = PROVISION_MUTEX.lock();
|
||||||
|
info!("Iniciando provisionamento do RustDesk");
|
||||||
|
|
||||||
|
// Prepara ACLs dos diretorios de servico
|
||||||
|
if let Err(e) = ensure_service_profiles_writable() {
|
||||||
|
warn!("Aviso ao preparar ACL: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le ID existente antes de qualquer limpeza
|
||||||
|
let preserved_remote_id = read_remote_id_from_profiles();
|
||||||
|
if let Some(ref id) = preserved_remote_id {
|
||||||
|
info!("ID existente preservado: {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe_path = detect_executable_path();
|
||||||
|
let (installed_version, freshly_installed) = ensure_installed(&exe_path)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"RustDesk {}: {}",
|
||||||
|
if freshly_installed { "instalado" } else { "ja presente" },
|
||||||
|
exe_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Para processos existentes
|
||||||
|
let _ = stop_rustdesk_processes();
|
||||||
|
|
||||||
|
// Limpa perfis apenas se instalacao fresca
|
||||||
|
if freshly_installed {
|
||||||
|
let _ = purge_existing_rustdesk_profiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplica configuracao
|
||||||
|
if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) {
|
||||||
|
if let Err(e) = run_with_args(&exe_path, &["--config", config]) {
|
||||||
|
warn!("Falha ao aplicar config inline: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let config_path = write_config_files()?;
|
||||||
|
if let Err(e) = apply_config(&exe_path, &config_path) {
|
||||||
|
warn!("Falha ao aplicar config via CLI: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define senha
|
||||||
|
let password = password_override
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
||||||
|
|
||||||
|
if let Err(e) = set_password(&exe_path, &password) {
|
||||||
|
warn!("Falha ao definir senha: {}", e);
|
||||||
|
} else {
|
||||||
|
let _ = ensure_password_files(&password);
|
||||||
|
let _ = propagate_password_profile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define ID customizado
|
||||||
|
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
|
||||||
|
if !freshly_installed {
|
||||||
|
Some(existing_id.clone())
|
||||||
|
} else {
|
||||||
|
define_custom_id(&exe_path, machine_id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
define_custom_id(&exe_path, machine_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inicia servico
|
||||||
|
if let Err(e) = ensure_service_running(&exe_path) {
|
||||||
|
warn!("Falha ao iniciar servico: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtem ID final
|
||||||
|
let final_id = match query_id_with_retries(&exe_path, 5) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
read_remote_id_from_profiles()
|
||||||
|
.or_else(|| custom_id.clone())
|
||||||
|
.ok_or(RustdeskError::MissingId)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garante ID em todos os arquivos
|
||||||
|
ensure_remote_id_files(&final_id);
|
||||||
|
|
||||||
|
let version = query_version(&exe_path).ok().or(installed_version);
|
||||||
|
let last_provisioned_at = Utc::now().timestamp_millis();
|
||||||
|
|
||||||
|
info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version);
|
||||||
|
|
||||||
|
Ok(RustdeskResult {
|
||||||
|
id: final_id,
|
||||||
|
password,
|
||||||
|
installed_version: version,
|
||||||
|
updated: freshly_installed,
|
||||||
|
last_provisioned_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retorna status do RustDesk
|
||||||
|
pub fn get_status() -> Result<RustdeskStatus, RustdeskError> {
|
||||||
|
let exe_path = detect_executable_path();
|
||||||
|
let installed = exe_path.exists();
|
||||||
|
|
||||||
|
let running = if installed {
|
||||||
|
query_service_state().map(|s| s == "running").unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = if installed {
|
||||||
|
query_id(&exe_path).ok().or_else(read_remote_id_from_profiles)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let version = if installed {
|
||||||
|
query_version(&exe_path).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RustdeskStatus {
|
||||||
|
installed,
|
||||||
|
running,
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Funcoes Auxiliares
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn detect_executable_path() -> PathBuf {
|
||||||
|
let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string());
|
||||||
|
Path::new(&program_files).join("RustDesk").join("rustdesk.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_installed(exe_path: &Path) -> Result<(Option<String>, bool), RustdeskError> {
|
||||||
|
if exe_path.exists() {
|
||||||
|
return Ok((None, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
|
||||||
|
.join(CACHE_DIR_NAME);
|
||||||
|
fs::create_dir_all(&cache_root)?;
|
||||||
|
|
||||||
|
let (installer_path, version_tag) = download_latest_installer(&cache_root)?;
|
||||||
|
run_installer(&installer_path)?;
|
||||||
|
thread::sleep(Duration::from_secs(20));
|
||||||
|
|
||||||
|
Ok((Some(version_tag), true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.ends_with("x86_64.exe"))
|
||||||
|
.ok_or(RustdeskError::AssetMissing)?;
|
||||||
|
|
||||||
|
let target_path = cache_root.join(&asset.name);
|
||||||
|
if target_path.exists() {
|
||||||
|
return Ok((target_path, release.tag_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Baixando RustDesk: {}", asset.name);
|
||||||
|
let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?;
|
||||||
|
let mut output = File::create(&target_path)?;
|
||||||
|
response.copy_to(&mut output)?;
|
||||||
|
|
||||||
|
Ok((target_path, release.tag_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> {
|
||||||
|
let status = hidden_command(installer_path)
|
||||||
|
.arg("--silent-install")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(RustdeskError::CommandFailed {
|
||||||
|
command: format!("{} --silent-install", installer_path.display()),
|
||||||
|
status: status.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn program_data_config_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
|
||||||
|
.join("RustDesk")
|
||||||
|
.join("config")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema
|
||||||
|
/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios
|
||||||
|
fn all_user_appdata_config_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
|
||||||
|
// Enumera C:\Users\*\AppData\Roaming\RustDesk\config
|
||||||
|
let users_dir = Path::new("C:\\Users");
|
||||||
|
if let Ok(entries) = fs::read_dir(users_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
// Ignora pastas de sistema
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config");
|
||||||
|
// Verifica se o diretorio pai existe (usuario real)
|
||||||
|
if path.join("AppData").join("Roaming").exists() {
|
||||||
|
dirs.push(rustdesk_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos)
|
||||||
|
if let Ok(appdata) = env::var("APPDATA") {
|
||||||
|
let path = Path::new(&appdata).join("RustDesk").join("config");
|
||||||
|
if !dirs.contains(&path) {
|
||||||
|
dirs.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_profile_dirs() -> Vec<PathBuf> {
|
||||||
|
vec![
|
||||||
|
PathBuf::from(LOCAL_SERVICE_CONFIG),
|
||||||
|
PathBuf::from(LOCAL_SYSTEM_CONFIG),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_id_directories() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
dirs.push(program_data_config_dir());
|
||||||
|
dirs.extend(service_profile_dirs());
|
||||||
|
dirs.extend(all_user_appdata_config_dirs());
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
||||||
|
let config_contents = format!(
|
||||||
|
r#"[options]
|
||||||
|
key = "{key}"
|
||||||
|
relay-server = "{host}"
|
||||||
|
custom-rendezvous-server = "{host}"
|
||||||
|
api-server = "https://{host}"
|
||||||
|
verification-method = "{verification}"
|
||||||
|
approve-mode = "{approve}"
|
||||||
|
"#,
|
||||||
|
host = SERVER_HOST,
|
||||||
|
key = SERVER_KEY,
|
||||||
|
verification = SECURITY_VERIFICATION_VALUE,
|
||||||
|
approve = SECURITY_APPROVE_MODE_VALUE,
|
||||||
|
);
|
||||||
|
|
||||||
|
let main_path = program_data_config_dir().join("RustDesk2.toml");
|
||||||
|
write_file(&main_path, &config_contents)?;
|
||||||
|
|
||||||
|
for service_dir in service_profile_dirs() {
|
||||||
|
let service_profile = service_dir.join("RustDesk2.toml");
|
||||||
|
let _ = write_file(&service_profile, &config_contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(main_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(contents.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> {
|
||||||
|
run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> {
|
||||||
|
run_with_args(exe_path, &["--password", secret])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option<String> {
|
||||||
|
let value = machine_id.and_then(|raw| {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let custom_id = derive_numeric_id(value);
|
||||||
|
if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() {
|
||||||
|
info!("ID deterministico definido: {}", custom_id);
|
||||||
|
Some(custom_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_numeric_id(machine_id: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(machine_id.as_bytes());
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
bytes.copy_from_slice(&hash[..8]);
|
||||||
|
let value = u64::from_le_bytes(bytes);
|
||||||
|
let num = (value % 900_000_000) + 100_000_000;
|
||||||
|
format!("{:09}", num)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
|
||||||
|
ensure_service_installed(exe_path)?;
|
||||||
|
let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]);
|
||||||
|
let _ = run_sc(&["start", SERVICE_NAME]);
|
||||||
|
remove_rustdesk_autorun_artifacts();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> {
|
||||||
|
if run_sc(&["query", SERVICE_NAME]).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
run_with_args(exe_path, &["--install-service"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
|
||||||
|
let _ = run_sc(&["stop", SERVICE_NAME]);
|
||||||
|
thread::sleep(Duration::from_secs(2));
|
||||||
|
|
||||||
|
let status = hidden_command("taskkill")
|
||||||
|
.args(["/F", "/T", "/IM", "rustdesk.exe"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
if status.success() || matches!(status.code(), Some(128)) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(RustdeskError::CommandFailed {
|
||||||
|
command: "taskkill".into(),
|
||||||
|
status: status.code(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
||||||
|
let files = [
|
||||||
|
"RustDesk.toml",
|
||||||
|
"RustDesk_local.toml",
|
||||||
|
"RustDesk2.toml",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"passwd.txt",
|
||||||
|
];
|
||||||
|
|
||||||
|
for dir in remote_id_directories() {
|
||||||
|
if !dir.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for name in files {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if path.exists() {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_password_files(secret: &str) -> Result<(), String> {
|
||||||
|
for dir in remote_id_directories() {
|
||||||
|
let password_path = dir.join("RustDesk.toml");
|
||||||
|
let _ = write_toml_kv(&password_path, "password", secret);
|
||||||
|
|
||||||
|
let local_path = dir.join("RustDesk_local.toml");
|
||||||
|
let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE);
|
||||||
|
let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propagate_password_profile() -> io::Result<bool> {
|
||||||
|
// Encontra um diretorio de usuario que tenha arquivos de config
|
||||||
|
let user_dirs = all_user_appdata_config_dirs();
|
||||||
|
let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists());
|
||||||
|
|
||||||
|
let Some(src_dir) = src_dir else {
|
||||||
|
// Se nenhum usuario tem config, usa ProgramData como fonte
|
||||||
|
let pd = program_data_config_dir();
|
||||||
|
if !pd.join("RustDesk.toml").exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
return propagate_from_dir(&pd);
|
||||||
|
};
|
||||||
|
|
||||||
|
propagate_from_dir(src_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propagate_from_dir(src_dir: &Path) -> io::Result<bool> {
|
||||||
|
let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"];
|
||||||
|
let mut propagated = false;
|
||||||
|
|
||||||
|
for filename in propagation_files {
|
||||||
|
let src_path = src_dir.join(filename);
|
||||||
|
if !src_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for dest_root in remote_id_directories() {
|
||||||
|
if dest_root == src_dir {
|
||||||
|
continue; // Nao copiar para si mesmo
|
||||||
|
}
|
||||||
|
let target_path = dest_root.join(filename);
|
||||||
|
if copy_overwrite(&src_path, &target_path).is_ok() {
|
||||||
|
propagated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(propagated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_remote_id_files(id: &str) {
|
||||||
|
for dir in remote_id_directories() {
|
||||||
|
let path = dir.join("RustDesk_local.toml");
|
||||||
|
let _ = write_remote_id_value(&path, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let replacement = format!("remote_id = '{}'\n", id);
|
||||||
|
if let Ok(existing) = fs::read_to_string(path) {
|
||||||
|
let mut replaced = false;
|
||||||
|
let mut buffer = String::with_capacity(existing.len() + replacement.len());
|
||||||
|
for line in existing.lines() {
|
||||||
|
if line.trim_start().starts_with("remote_id") {
|
||||||
|
buffer.push_str(&replacement);
|
||||||
|
replaced = true;
|
||||||
|
} else {
|
||||||
|
buffer.push_str(line);
|
||||||
|
buffer.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
buffer.push_str(&replacement);
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(buffer.as_bytes())
|
||||||
|
} else {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(replacement.as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let sanitized = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
let replacement = format!("{key} = \"{sanitized}\"\n");
|
||||||
|
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
let mut replaced = false;
|
||||||
|
let mut buffer = String::with_capacity(existing.len() + replacement.len());
|
||||||
|
for line in existing.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
|
||||||
|
buffer.push_str(&replacement);
|
||||||
|
replaced = true;
|
||||||
|
} else {
|
||||||
|
buffer.push_str(line);
|
||||||
|
buffer.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
buffer.push_str(&replacement);
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(buffer.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_remote_id_from_profiles() -> Option<String> {
|
||||||
|
for dir in remote_id_directories() {
|
||||||
|
for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] {
|
||||||
|
if let Some(id) = read_remote_id_file(&candidate) {
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_remote_id_file(path: &Path) -> Option<String> {
|
||||||
|
let content = fs::read_to_string(path).ok()?;
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some(value) = parse_assignment(line, "remote_id") {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_assignment(line: &str, key: &str) -> Option<String> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.starts_with(key) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (_, rhs) = trimmed.split_once('=')?;
|
||||||
|
let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result<String, RustdeskError> {
|
||||||
|
for attempt in 0..attempts {
|
||||||
|
match query_id(exe_path) {
|
||||||
|
Ok(value) if !value.trim().is_empty() => return Ok(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if attempt + 1 < attempts {
|
||||||
|
thread::sleep(Duration::from_millis(800));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RustdeskError::MissingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_id(exe_path: &Path) -> Result<String, RustdeskError> {
|
||||||
|
let output = hidden_command(exe_path).arg("--get-id").output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(RustdeskError::CommandFailed {
|
||||||
|
command: format!("{} --get-id", exe_path.display()),
|
||||||
|
status: output.status.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return Err(RustdeskError::MissingId);
|
||||||
|
}
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_version(exe_path: &Path) -> Result<String, RustdeskError> {
|
||||||
|
let output = hidden_command(exe_path).arg("--version").output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(RustdeskError::CommandFailed {
|
||||||
|
command: format!("{} --version", exe_path.display()),
|
||||||
|
status: output.status.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_service_state() -> Option<String> {
|
||||||
|
let output = hidden_command("sc")
|
||||||
|
.args(["query", SERVICE_NAME])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let lower = line.to_lowercase();
|
||||||
|
if lower.contains("running") {
|
||||||
|
return Some("running".to_string());
|
||||||
|
}
|
||||||
|
if lower.contains("stopped") {
|
||||||
|
return Some("stopped".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sc(args: &[&str]) -> Result<(), RustdeskError> {
|
||||||
|
let status = hidden_command("sc")
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(RustdeskError::CommandFailed {
|
||||||
|
command: format!("sc {}", args.join(" ")),
|
||||||
|
status: status.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> {
|
||||||
|
let status = hidden_command(exe_path)
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(RustdeskError::CommandFailed {
|
||||||
|
command: format!("{} {}", exe_path.display(), args.join(" ")),
|
||||||
|
status: status.code(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_rustdesk_autorun_artifacts() {
|
||||||
|
// Remove atalhos de inicializacao automatica
|
||||||
|
let mut startup_paths: Vec<PathBuf> = Vec::new();
|
||||||
|
if let Ok(appdata) = env::var("APPDATA") {
|
||||||
|
startup_paths.push(
|
||||||
|
Path::new(&appdata)
|
||||||
|
.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
startup_paths.push(PathBuf::from(
|
||||||
|
r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk",
|
||||||
|
));
|
||||||
|
|
||||||
|
for path in startup_paths {
|
||||||
|
if path.exists() {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entradas de registro
|
||||||
|
for hive in ["HKCU", "HKLM"] {
|
||||||
|
let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive);
|
||||||
|
let _ = hidden_command("reg")
|
||||||
|
.args(["delete", ®_path, "/v", "RustDesk", "/f"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_service_profiles_writable() -> Result<(), String> {
|
||||||
|
for dir in service_profile_dirs() {
|
||||||
|
if !can_write_dir(&dir) {
|
||||||
|
fix_profile_acl(&dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_write_dir(dir: &Path) -> bool {
|
||||||
|
if fs::create_dir_all(dir).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let probe = dir.join(".raven_acl_probe");
|
||||||
|
match OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&probe)
|
||||||
|
{
|
||||||
|
Ok(mut file) => {
|
||||||
|
if file.write_all(b"ok").is_err() {
|
||||||
|
let _ = fs::remove_file(&probe);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&probe);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fix_profile_acl(target: &Path) -> Result<(), String> {
|
||||||
|
let target_str = target.display().to_string();
|
||||||
|
|
||||||
|
// Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente
|
||||||
|
let _ = hidden_command("takeown")
|
||||||
|
.args(["/F", &target_str, "/R", "/D", "Y"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
|
||||||
|
let status = hidden_command("icacls")
|
||||||
|
.args([
|
||||||
|
&target_str,
|
||||||
|
"/grant",
|
||||||
|
"*S-1-5-32-544:(OI)(CI)F",
|
||||||
|
"*S-1-5-19:(OI)(CI)F",
|
||||||
|
"*S-1-5-32-545:(OI)(CI)M",
|
||||||
|
"/T",
|
||||||
|
"/C",
|
||||||
|
"/Q",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Erro ao executar icacls: {}", e))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
if dst.is_dir() {
|
||||||
|
fs::remove_dir_all(dst)?;
|
||||||
|
} else if dst.exists() {
|
||||||
|
fs::remove_file(dst)?;
|
||||||
|
}
|
||||||
|
fs::copy(src, dst)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hidden_command(program: impl AsRef<OsStr>) -> Command {
|
||||||
|
let mut cmd = Command::new(program);
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
259
apps/desktop/service/src/usb_policy.rs
Normal file
259
apps/desktop/service/src/usb_policy.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
//! Modulo USB Policy - Controle de dispositivos USB
|
||||||
|
//!
|
||||||
|
//! Implementa o controle de armazenamento USB no Windows.
|
||||||
|
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use winreg::enums::*;
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
// GUID para Removable Storage Devices (Disk)
|
||||||
|
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
|
||||||
|
|
||||||
|
// Chaves de registro
|
||||||
|
const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
|
||||||
|
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
|
||||||
|
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum UsbPolicy {
|
||||||
|
Allow,
|
||||||
|
BlockAll,
|
||||||
|
Readonly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsbPolicy {
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_uppercase().as_str() {
|
||||||
|
"ALLOW" => Some(Self::Allow),
|
||||||
|
"BLOCK_ALL" => Some(Self::BlockAll),
|
||||||
|
"READONLY" => Some(Self::Readonly),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Allow => "ALLOW",
|
||||||
|
Self::BlockAll => "BLOCK_ALL",
|
||||||
|
Self::Readonly => "READONLY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UsbPolicyResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub policy: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub applied_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum UsbControlError {
|
||||||
|
#[error("Politica USB invalida: {0}")]
|
||||||
|
InvalidPolicy(String),
|
||||||
|
|
||||||
|
#[error("Erro de registro do Windows: {0}")]
|
||||||
|
RegistryError(String),
|
||||||
|
|
||||||
|
#[error("Permissao negada")]
|
||||||
|
PermissionDenied,
|
||||||
|
|
||||||
|
#[error("Erro de I/O: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica uma politica de USB
|
||||||
|
pub fn apply_policy(policy_str: &str) -> Result<UsbPolicyResult, UsbControlError> {
|
||||||
|
let policy = UsbPolicy::from_str(policy_str)
|
||||||
|
.ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
|
info!("Aplicando politica USB: {:?}", policy);
|
||||||
|
|
||||||
|
// 1. Aplicar Removable Storage Policy
|
||||||
|
apply_removable_storage_policy(policy)?;
|
||||||
|
|
||||||
|
// 2. Aplicar USBSTOR
|
||||||
|
apply_usbstor_policy(policy)?;
|
||||||
|
|
||||||
|
// 3. Aplicar WriteProtect se necessario
|
||||||
|
if policy == UsbPolicy::Readonly {
|
||||||
|
apply_write_protect(true)?;
|
||||||
|
} else {
|
||||||
|
apply_write_protect(false)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Atualizar Group Policy (opcional)
|
||||||
|
if let Err(e) = refresh_group_policy() {
|
||||||
|
warn!("Falha ao atualizar group policy: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Politica USB aplicada com sucesso: {:?}", policy);
|
||||||
|
|
||||||
|
Ok(UsbPolicyResult {
|
||||||
|
success: true,
|
||||||
|
policy: policy.as_str().to_string(),
|
||||||
|
error: None,
|
||||||
|
applied_at: Some(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retorna a politica USB atual
|
||||||
|
pub fn get_current_policy() -> Result<String, UsbControlError> {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
|
||||||
|
// Verifica Removable Storage Policy primeiro
|
||||||
|
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||||
|
|
||||||
|
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
|
||||||
|
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
|
||||||
|
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
|
||||||
|
|
||||||
|
if deny_read == 1 && deny_write == 1 {
|
||||||
|
return Ok("BLOCK_ALL".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if deny_read == 0 && deny_write == 1 {
|
||||||
|
return Ok("READONLY".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica USBSTOR como fallback
|
||||||
|
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
|
||||||
|
let start: u32 = key.get_value("Start").unwrap_or(3);
|
||||||
|
if start == 4 {
|
||||||
|
return Ok("BLOCK_ALL".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok("ALLOW".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||||
|
|
||||||
|
match policy {
|
||||||
|
UsbPolicy::Allow => {
|
||||||
|
// Tenta remover as restricoes, se existirem
|
||||||
|
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
|
||||||
|
let _ = key.delete_value("Deny_Read");
|
||||||
|
let _ = key.delete_value("Deny_Write");
|
||||||
|
let _ = key.delete_value("Deny_Execute");
|
||||||
|
}
|
||||||
|
// Tenta remover a chave inteira se estiver vazia
|
||||||
|
let _ = hklm.delete_subkey(&full_path);
|
||||||
|
}
|
||||||
|
UsbPolicy::BlockAll => {
|
||||||
|
let (key, _) = hklm
|
||||||
|
.create_subkey(&full_path)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
|
key.set_value("Deny_Read", &1u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
key.set_value("Deny_Write", &1u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
key.set_value("Deny_Execute", &1u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
}
|
||||||
|
UsbPolicy::Readonly => {
|
||||||
|
let (key, _) = hklm
|
||||||
|
.create_subkey(&full_path)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
|
// Permite leitura, bloqueia escrita
|
||||||
|
key.set_value("Deny_Read", &0u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
key.set_value("Deny_Write", &1u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
key.set_value("Deny_Execute", &0u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
|
||||||
|
let key = hklm
|
||||||
|
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
|
match policy {
|
||||||
|
UsbPolicy::Allow => {
|
||||||
|
// Start = 3 habilita o driver
|
||||||
|
key.set_value("Start", &3u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
}
|
||||||
|
UsbPolicy::BlockAll => {
|
||||||
|
// Start = 4 desabilita o driver
|
||||||
|
key.set_value("Start", &4u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
}
|
||||||
|
UsbPolicy::Readonly => {
|
||||||
|
// Readonly mantem driver ativo
|
||||||
|
key.set_value("Start", &3u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
|
||||||
|
if enable {
|
||||||
|
let (key, _) = hklm
|
||||||
|
.create_subkey(STORAGE_POLICY_PATH)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
|
key.set_value("WriteProtect", &1u32)
|
||||||
|
.map_err(map_winreg_error)?;
|
||||||
|
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||||
|
let _ = key.set_value("WriteProtect", &0u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_group_policy() -> Result<(), UsbControlError> {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let output = Command::new("gpupdate")
|
||||||
|
.args(["/target:computer", "/force"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map_err(UsbControlError::Io)?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
warn!(
|
||||||
|
"gpupdate retornou erro: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_winreg_error(error: io::Error) -> UsbControlError {
|
||||||
|
if let Some(code) = error.raw_os_error() {
|
||||||
|
if code == 5 {
|
||||||
|
return UsbControlError::PermissionDenied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UsbControlError::RegistryError(error.to_string())
|
||||||
|
}
|
||||||
130
apps/desktop/src-tauri/Cargo.lock
generated
130
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -63,6 +63,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"convex",
|
"convex",
|
||||||
|
"dirs 5.0.1",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"get_if_addrs",
|
"get_if_addrs",
|
||||||
"hostname",
|
"hostname",
|
||||||
|
|
@ -80,10 +81,12 @@ dependencies = [
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -936,13 +939,34 @@ dependencies = [
|
||||||
"crypto-common",
|
"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]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys",
|
"dirs-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users 0.4.6",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -953,7 +977,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3627,6 +3651,17 @@ dependencies = [
|
||||||
"bitflags 2.9.4",
|
"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]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
|
|
@ -4514,7 +4549,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
|
|
@ -4564,7 +4599,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
|
|
@ -4748,6 +4783,21 @@ dependencies = [
|
||||||
"tauri-plugin",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-store"
|
name = "tauri-plugin-store"
|
||||||
version = "2.4.0"
|
version = "2.4.0"
|
||||||
|
|
@ -4771,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
|
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -5307,7 +5357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
|
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2 0.6.3",
|
"objc2 0.6.3",
|
||||||
|
|
@ -6088,6 +6138,15 @@ dependencies = [
|
||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|
@ -6139,6 +6198,21 @@ dependencies = [
|
||||||
"windows_x86_64_msvc 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6196,6 +6270,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6214,6 +6294,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6232,6 +6318,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6262,6 +6354,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6280,6 +6378,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6298,6 +6402,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6316,6 +6426,12 @@ version = "0.42.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6378,7 +6494,7 @@ dependencies = [
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
"gdkx11",
|
"gdkx11",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ tauri-plugin-updater = "2.9.0"
|
||||||
tauri-plugin-process = "2.3.0"
|
tauri-plugin-process = "2.3.0"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||||
|
|
@ -41,6 +42,8 @@ hostname = "0.4"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
convex = "0.10.2"
|
convex = "0.10.2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
dirs = "5"
|
||||||
# SSE usa reqwest com stream, nao precisa de websocket
|
# SSE usa reqwest com stream, nao precisa de websocket
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for all windows",
|
"description": "Capability for all windows",
|
||||||
"windows": ["main", "chat-*"],
|
"windows": ["main", "chat-*", "chat-hub"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"store:default",
|
"store:default",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,121 @@
|
||||||
; Hooks customizadas do instalador NSIS (Tauri)
|
; Hooks customizadas do instalador NSIS (Tauri)
|
||||||
;
|
;
|
||||||
; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo.
|
; Objetivo:
|
||||||
|
; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo
|
||||||
|
; - Instalar o Raven Service para operacoes privilegiadas sem UAC
|
||||||
;
|
;
|
||||||
; Nota: o bundler do Tauri injeta estes macros no script principal do instalador.
|
; Nota: o bundler do Tauri injeta estes macros no script principal do instalador.
|
||||||
|
|
||||||
BrandingText " "
|
BrandingText " "
|
||||||
|
|
||||||
!macro NSIS_HOOK_PREINSTALL
|
!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
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_POSTINSTALL
|
!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
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_PREUNINSTALL
|
!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
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_POSTUNINSTALL
|
!macro NSIS_HOOK_POSTUNINSTALL
|
||||||
|
; Nada adicional necessario
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> {
|
fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> {
|
||||||
if bytes.len() % 2 != 0 {
|
if !bytes.len().is_multiple_of(2) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let utf16: Vec<u16> = bytes
|
let utf16: Vec<u16> = bytes
|
||||||
|
|
@ -971,6 +971,169 @@ fn collect_windows_extended() -> serde_json::Value {
|
||||||
"#).unwrap_or_else(|| json!([]));
|
"#).unwrap_or_else(|| json!([]));
|
||||||
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").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!({
|
json!({
|
||||||
"windows": {
|
"windows": {
|
||||||
"software": software,
|
"software": software,
|
||||||
|
|
@ -992,6 +1155,12 @@ fn collect_windows_extended() -> serde_json::Value {
|
||||||
"windowsUpdate": windows_update,
|
"windowsUpdate": windows_update,
|
||||||
"computerSystem": computer_system,
|
"computerSystem": computer_system,
|
||||||
"azureAdStatus": device_join,
|
"azureAdStatus": device_join,
|
||||||
|
"battery": battery,
|
||||||
|
"thermal": thermal,
|
||||||
|
"networkAdapters": network_adapters,
|
||||||
|
"monitors": monitors,
|
||||||
|
"chassis": power_supply,
|
||||||
|
"bootInfo": boot_info,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1086,7 +1255,7 @@ pub fn collect_profile() -> Result<MachineProfile, AgentError> {
|
||||||
let system = collect_system();
|
let system = collect_system();
|
||||||
|
|
||||||
let os_name = System::name()
|
let os_name = System::name()
|
||||||
.or_else(|| System::long_os_version())
|
.or_else(System::long_os_version)
|
||||||
.unwrap_or_else(|| "desconhecido".to_string());
|
.unwrap_or_else(|| "desconhecido".to_string());
|
||||||
let os_version = System::os_version();
|
let os_version = System::os_version();
|
||||||
let architecture = std::env::consts::ARCH.to_string();
|
let architecture = std::env::consts::ARCH.to_string();
|
||||||
|
|
@ -1146,7 +1315,7 @@ async fn post_heartbeat(
|
||||||
.into_owned();
|
.into_owned();
|
||||||
let os = MachineOs {
|
let os = MachineOs {
|
||||||
name: System::name()
|
name: System::name()
|
||||||
.or_else(|| System::long_os_version())
|
.or_else(System::long_os_version)
|
||||||
.unwrap_or_else(|| "desconhecido".to_string()),
|
.unwrap_or_else(|| "desconhecido".to_string()),
|
||||||
version: System::os_version(),
|
version: System::os_version(),
|
||||||
architecture: Some(std::env::consts::ARCH.to_string()),
|
architecture: Some(std::env::consts::ARCH.to_string()),
|
||||||
|
|
@ -1225,7 +1394,8 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy};
|
use crate::usb_control::{get_current_policy, UsbPolicy};
|
||||||
|
use crate::service_client;
|
||||||
|
|
||||||
let policy = match UsbPolicy::from_str(&policy_str) {
|
let policy = match UsbPolicy::from_str(&policy_str) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
|
|
@ -1259,13 +1429,40 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
||||||
// Reporta APPLYING para progress bar real no frontend
|
// Reporta APPLYING para progress bar real no frontend
|
||||||
let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await;
|
let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await;
|
||||||
|
|
||||||
match apply_usb_policy(policy) {
|
// Tenta primeiro via RavenService (privilegiado)
|
||||||
|
crate::log_info!("Tentando aplicar politica via RavenService...");
|
||||||
|
match service_client::apply_usb_policy(&policy_str) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
crate::log_info!("Politica USB aplicada com sucesso: {:?}", 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;
|
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
|
||||||
if !reported {
|
if !reported {
|
||||||
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
|
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
|
||||||
// Agenda retry em background
|
|
||||||
let base_url = base_url.to_string();
|
let base_url = base_url.to_string();
|
||||||
let token = token.to_string();
|
let token = token.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -1276,7 +1473,14 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_error!("Falha ao aplicar politica USB: {e}");
|
let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e);
|
||||||
|
crate::log_error!("{}", err_msg);
|
||||||
|
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::log_error!("Falha ao comunicar com RavenService: {e}");
|
||||||
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
|
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ use once_cell::sync::Lazy;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::async_runtime::JoinHandle;
|
use tauri::async_runtime::JoinHandle;
|
||||||
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
|
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
@ -100,6 +102,77 @@ pub struct SessionStartedEvent {
|
||||||
pub session: ChatSession,
|
pub session: ChatSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PERSISTENCIA DE ESTADO
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Estado persistido do chat para sobreviver a restarts
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChatPersistedState {
|
||||||
|
last_unread_count: u32,
|
||||||
|
sessions: Vec<ChatSession>,
|
||||||
|
saved_at: u64, // Unix timestamp em ms
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_FILE_NAME: &str = "chat-state.json";
|
||||||
|
const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos
|
||||||
|
|
||||||
|
fn get_state_file_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) {
|
||||||
|
let Some(path) = get_state_file_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Criar diretorio se nao existir
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let state = ChatPersistedState {
|
||||||
|
last_unread_count: last_unread,
|
||||||
|
sessions: sessions.to_vec(),
|
||||||
|
saved_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&state) {
|
||||||
|
let _ = fs::write(&path, json);
|
||||||
|
crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_chat_state() -> Option<ChatPersistedState> {
|
||||||
|
let path = get_state_file_path()?;
|
||||||
|
|
||||||
|
let json = fs::read_to_string(&path).ok()?;
|
||||||
|
let state: ChatPersistedState = serde_json::from_str(&json).ok()?;
|
||||||
|
|
||||||
|
// Verificar se estado nao esta muito antigo
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS {
|
||||||
|
crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::log_info!(
|
||||||
|
"[CHAT] Estado restaurado: unread={}, sessions={}",
|
||||||
|
state.last_unread_count, state.sessions.len()
|
||||||
|
);
|
||||||
|
Some(state)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HTTP CLIENT
|
// HTTP CLIENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -321,6 +394,7 @@ pub struct UploadResult {
|
||||||
// Extensoes permitidas
|
// Extensoes permitidas
|
||||||
const ALLOWED_EXTENSIONS: &[&str] = &[
|
const ALLOWED_EXTENSIONS: &[&str] = &[
|
||||||
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||||
|
".mp3", ".wav", ".ogg", ".webm", ".m4a",
|
||||||
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -361,6 +435,11 @@ pub fn get_mime_type(file_name: &str) -> String {
|
||||||
"png" => "image/png",
|
"png" => "image/png",
|
||||||
"gif" => "image/gif",
|
"gif" => "image/gif",
|
||||||
"webp" => "image/webp",
|
"webp" => "image/webp",
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"ogg" => "audio/ogg",
|
||||||
|
"webm" => "audio/webm",
|
||||||
|
"m4a" => "audio/mp4",
|
||||||
"pdf" => "application/pdf",
|
"pdf" => "application/pdf",
|
||||||
"txt" => "text/plain",
|
"txt" => "text/plain",
|
||||||
"doc" => "application/msword",
|
"doc" => "application/msword",
|
||||||
|
|
@ -462,10 +541,16 @@ pub struct ChatRuntime {
|
||||||
|
|
||||||
impl ChatRuntime {
|
impl ChatRuntime {
|
||||||
pub fn new() -> Self {
|
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 {
|
Self {
|
||||||
inner: Arc::new(Mutex::new(None)),
|
inner: Arc::new(Mutex::new(None)),
|
||||||
last_sessions: Arc::new(Mutex::new(Vec::new())),
|
last_sessions: Arc::new(Mutex::new(sessions)),
|
||||||
last_unread_count: Arc::new(Mutex::new(0)),
|
last_unread_count: Arc::new(Mutex::new(unread)),
|
||||||
is_connected: Arc::new(AtomicBool::new(false)),
|
is_connected: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +595,9 @@ impl ChatRuntime {
|
||||||
let is_connected = self.is_connected.clone();
|
let is_connected = self.is_connected.clone();
|
||||||
|
|
||||||
let join_handle = tauri::async_runtime::spawn(async move {
|
let join_handle = tauri::async_runtime::spawn(async move {
|
||||||
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)");
|
crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat");
|
||||||
|
crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone);
|
||||||
|
crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone);
|
||||||
|
|
||||||
let mut backoff_ms: u64 = 1_000;
|
let mut backoff_ms: u64 = 1_000;
|
||||||
let max_backoff_ms: u64 = 30_000;
|
let max_backoff_ms: u64 = 30_000;
|
||||||
|
|
@ -522,12 +609,16 @@ impl ChatRuntime {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex...");
|
||||||
let client_result = ConvexClient::new(&convex_clone).await;
|
let client_result = ConvexClient::new(&convex_clone).await;
|
||||||
let mut client = match client_result {
|
let mut client = match client_result {
|
||||||
Ok(c) => c,
|
Ok(c) => {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso");
|
||||||
|
c
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
is_connected.store(false, Ordering::Relaxed);
|
is_connected.store(false, Ordering::Relaxed);
|
||||||
crate::log_warn!("Falha ao criar cliente Convex: {err:?}");
|
crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}");
|
||||||
|
|
||||||
if last_poll.elapsed() >= poll_interval {
|
if last_poll.elapsed() >= poll_interval {
|
||||||
poll_and_process_chat_update(
|
poll_and_process_chat_update(
|
||||||
|
|
@ -550,16 +641,18 @@ impl ChatRuntime {
|
||||||
let mut args = BTreeMap::new();
|
let mut args = BTreeMap::new();
|
||||||
args.insert("machineToken".to_string(), token_clone.clone().into());
|
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 subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await;
|
||||||
let mut subscription = match subscribe_result {
|
let mut subscription = match subscribe_result {
|
||||||
Ok(sub) => {
|
Ok(sub) => {
|
||||||
is_connected.store(true, Ordering::Relaxed);
|
is_connected.store(true, Ordering::Relaxed);
|
||||||
backoff_ms = 1_000;
|
backoff_ms = 1_000;
|
||||||
|
crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!");
|
||||||
sub
|
sub
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
is_connected.store(false, Ordering::Relaxed);
|
is_connected.store(false, Ordering::Relaxed);
|
||||||
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}");
|
crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}");
|
||||||
|
|
||||||
if last_poll.elapsed() >= poll_interval {
|
if last_poll.elapsed() >= poll_interval {
|
||||||
poll_and_process_chat_update(
|
poll_and_process_chat_update(
|
||||||
|
|
@ -579,8 +672,12 @@ impl ChatRuntime {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket...");
|
||||||
|
let mut update_count: u64 = 0;
|
||||||
while let Some(next) = subscription.next().await {
|
while let Some(next) = subscription.next().await {
|
||||||
|
update_count += 1;
|
||||||
if stop_clone.load(Ordering::Relaxed) {
|
if stop_clone.load(Ordering::Relaxed) {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
match next {
|
match next {
|
||||||
|
|
@ -601,6 +698,11 @@ impl ChatRuntime {
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
crate::log_info!(
|
||||||
|
"[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}",
|
||||||
|
update_count, has_active, total_unread
|
||||||
|
);
|
||||||
|
|
||||||
process_chat_update(
|
process_chat_update(
|
||||||
&base_clone,
|
&base_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
|
|
@ -613,13 +715,13 @@ impl ChatRuntime {
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
FunctionResult::ConvexError(err) => {
|
FunctionResult::ConvexError(err) => {
|
||||||
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}");
|
crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}");
|
||||||
}
|
}
|
||||||
FunctionResult::ErrorMessage(msg) => {
|
FunctionResult::ErrorMessage(msg) => {
|
||||||
crate::log_warn!("Erro em checkMachineUpdates: {msg}");
|
crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}");
|
||||||
}
|
}
|
||||||
FunctionResult::Value(other) => {
|
FunctionResult::Value(other) => {
|
||||||
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}");
|
crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -627,10 +729,11 @@ impl ChatRuntime {
|
||||||
is_connected.store(false, Ordering::Relaxed);
|
is_connected.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
if stop_clone.load(Ordering::Relaxed) {
|
if stop_clone.load(Ordering::Relaxed) {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar");
|
crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar...");
|
||||||
if last_poll.elapsed() >= poll_interval {
|
if last_poll.elapsed() >= poll_interval {
|
||||||
poll_and_process_chat_update(
|
poll_and_process_chat_update(
|
||||||
&base_clone,
|
&base_clone,
|
||||||
|
|
@ -684,8 +787,13 @@ async fn poll_and_process_chat_update(
|
||||||
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
||||||
last_unread_count: &Arc<Mutex<u32>>,
|
last_unread_count: &Arc<Mutex<u32>>,
|
||||||
) {
|
) {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling...");
|
||||||
match poll_chat_updates(base_url, token, None).await {
|
match poll_chat_updates(base_url, token, None).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
crate::log_info!(
|
||||||
|
"[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}",
|
||||||
|
result.has_active_sessions, result.total_unread
|
||||||
|
);
|
||||||
process_chat_update(
|
process_chat_update(
|
||||||
base_url,
|
base_url,
|
||||||
token,
|
token,
|
||||||
|
|
@ -698,7 +806,7 @@ async fn poll_and_process_chat_update(
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
crate::log_warn!("Chat fallback poll falhou: {err}");
|
crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -712,10 +820,18 @@ async fn process_chat_update(
|
||||||
has_active_sessions: bool,
|
has_active_sessions: bool,
|
||||||
total_unread: u32,
|
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
|
// Buscar sessoes completas para ter dados corretos
|
||||||
let mut current_sessions = if has_active_sessions {
|
let mut current_sessions = if has_active_sessions {
|
||||||
fetch_sessions(base_url, token).await.unwrap_or_default()
|
let sessions = fetch_sessions(base_url, token).await.unwrap_or_default();
|
||||||
|
crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len());
|
||||||
|
sessions
|
||||||
} else {
|
} else {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Sem sessoes ativas");
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -776,14 +892,58 @@ async fn process_chat_update(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar cache de sessoes
|
// =========================================================================
|
||||||
*last_sessions.lock() = current_sessions.clone();
|
// DETECCAO ROBUSTA DE NOVAS MENSAGENS
|
||||||
|
// Usa DUAS estrategias: timestamp E contador (belt and suspenders)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
// Verificar mensagens nao lidas
|
|
||||||
let prev_unread = *last_unread_count.lock();
|
let prev_unread = *last_unread_count.lock();
|
||||||
let new_messages = total_unread > prev_unread;
|
|
||||||
|
// Estrategia 1: Detectar por lastActivityAt de cada sessao
|
||||||
|
// Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem
|
||||||
|
let mut detected_by_activity = false;
|
||||||
|
let mut activity_details = String::new();
|
||||||
|
|
||||||
|
for session in ¤t_sessions {
|
||||||
|
let prev_activity = prev_sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.session_id == session.session_id)
|
||||||
|
.map(|s| s.last_activity_at)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente
|
||||||
|
if session.last_activity_at > prev_activity && session.unread_count > 0 {
|
||||||
|
detected_by_activity = true;
|
||||||
|
activity_details = format!(
|
||||||
|
"sessao={} activity: {} -> {} unread={}",
|
||||||
|
session.ticket_id, prev_activity, session.last_activity_at, session.unread_count
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrategia 2: Fallback por contador total (metodo original)
|
||||||
|
let detected_by_count = total_unread > prev_unread;
|
||||||
|
|
||||||
|
// Nova mensagem se QUALQUER estrategia detectar
|
||||||
|
let new_messages = detected_by_activity || detected_by_count;
|
||||||
|
|
||||||
|
// Log detalhado para diagnostico
|
||||||
|
crate::log_info!(
|
||||||
|
"[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}",
|
||||||
|
detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages
|
||||||
|
);
|
||||||
|
if detected_by_activity {
|
||||||
|
crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar caches APOS deteccao (importante: manter ordem)
|
||||||
|
*last_sessions.lock() = current_sessions.clone();
|
||||||
*last_unread_count.lock() = total_unread;
|
*last_unread_count.lock() = total_unread;
|
||||||
|
|
||||||
|
// Persistir estado para sobreviver a restarts
|
||||||
|
save_chat_state(total_unread, ¤t_sessions);
|
||||||
|
|
||||||
// Sempre emitir unread-update
|
// Sempre emitir unread-update
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"raven://chat/unread-update",
|
"raven://chat/unread-update",
|
||||||
|
|
@ -793,11 +953,25 @@ async fn process_chat_update(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if current_sessions.is_empty() {
|
||||||
|
close_all_chat_windows(app);
|
||||||
|
let _ = close_hub_window(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Notificar novas mensagens - mostrar chat minimizado com badge
|
// Notificar novas mensagens - mostrar chat minimizado com badge
|
||||||
if new_messages && total_unread > 0 {
|
if new_messages && total_unread > 0 {
|
||||||
let new_count = total_unread - prev_unread;
|
let new_count = if total_unread > prev_unread {
|
||||||
|
total_unread - prev_unread
|
||||||
|
} else {
|
||||||
|
1 // Se detectou por activity mas contador nao mudou, assumir 1 nova
|
||||||
|
};
|
||||||
|
|
||||||
crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread);
|
crate::log_info!(
|
||||||
|
"[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}",
|
||||||
|
new_count, total_unread,
|
||||||
|
if detected_by_activity { "activity" } else { "count" }
|
||||||
|
);
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"raven://chat/new-message",
|
"raven://chat/new-message",
|
||||||
|
|
@ -838,6 +1012,20 @@ async fn process_chat_update(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se ha multiplas sessoes ativas, usar o hub quando nao houver chat expandido.
|
||||||
|
//
|
||||||
|
// Importante (UX): nao mostrar hub e chat ao mesmo tempo.
|
||||||
|
if current_sessions.len() > 1 {
|
||||||
|
if has_expanded_chat_window() {
|
||||||
|
let _ = close_hub_window(app);
|
||||||
|
} else {
|
||||||
|
close_all_chat_windows(app);
|
||||||
|
let _ = open_hub_window(app);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Uma sessao - nao precisa de hub
|
||||||
|
let _ = close_hub_window(app);
|
||||||
|
|
||||||
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
||||||
let session_to_show = if best_delta > 0 {
|
let session_to_show = if best_delta > 0 {
|
||||||
best_session
|
best_session
|
||||||
|
|
@ -849,26 +1037,9 @@ async fn process_chat_update(
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra)
|
// Mostrar janela de chat (sempre minimizada/nao intrusiva)
|
||||||
if let Some(session) = session_to_show {
|
if let Some(session) = session_to_show {
|
||||||
let label = format!("chat-{}", session.ticket_id);
|
let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true);
|
||||||
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 {
|
|
||||||
// Criar nova janela ja minimizada (menos intrusivo)
|
|
||||||
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -885,6 +1056,16 @@ async fn process_chat_update(
|
||||||
.title(notification_title)
|
.title(notification_title)
|
||||||
.body(¬ification_body)
|
.body(¬ification_body)
|
||||||
.show();
|
.show();
|
||||||
|
} else {
|
||||||
|
// Log para debug quando NAO ha novas mensagens
|
||||||
|
if total_unread == 0 {
|
||||||
|
crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)");
|
||||||
|
} else if !new_messages {
|
||||||
|
crate::log_info!(
|
||||||
|
"[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})",
|
||||||
|
prev_unread, total_unread
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -892,6 +1073,53 @@ async fn process_chat_update(
|
||||||
// WINDOW MANAGEMENT
|
// WINDOW MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
|
||||||
|
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||||
|
static CHAT_WINDOW_STATE: Lazy<Mutex<HashMap<String, bool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
fn set_chat_window_state(label: &str, minimized: bool) {
|
||||||
|
CHAT_WINDOW_STATE.lock().insert(label.to_string(), minimized);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_chat_window_state(label: &str) {
|
||||||
|
CHAT_WINDOW_STATE.lock().remove(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_expanded_chat_window() -> bool {
|
||||||
|
CHAT_WINDOW_STATE.lock().values().any(|minimized| !*minimized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_all_chat_windows(app: &tauri::AppHandle) {
|
||||||
|
let labels: Vec<String> = app
|
||||||
|
.webview_windows()
|
||||||
|
.keys()
|
||||||
|
.filter(|label| label.starts_with("chat-") && *label != HUB_WINDOW_LABEL)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for label in labels {
|
||||||
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
clear_chat_window_state(&label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_other_chat_windows(app: &tauri::AppHandle, active_label: &str) {
|
||||||
|
for (label, window) in app.webview_windows() {
|
||||||
|
if !label.starts_with("chat-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if label == active_label {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = window.hide();
|
||||||
|
set_chat_window_state(&label, true);
|
||||||
|
}
|
||||||
|
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||||
|
let _ = hub.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_chat_window_position(
|
fn resolve_chat_window_position(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
window: Option<&tauri::WebviewWindow>,
|
window: Option<&tauri::WebviewWindow>,
|
||||||
|
|
@ -932,18 +1160,44 @@ fn resolve_chat_window_position(
|
||||||
(x, y)
|
(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
|
||||||
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada
|
let _guard = WINDOW_OP_LOCK.lock();
|
||||||
|
open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abre janela de chat com estado inicial de minimizacao configuravel
|
/// 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> {
|
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);
|
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
|
// Verificar se ja existe
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
let _ = window.set_ignore_cursor_events(false);
|
||||||
|
crate::log_info!("[WINDOW] {}: window existe -> show()", label);
|
||||||
window.show().map_err(|e| e.to_string())?;
|
window.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())?;
|
window.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
// Expandir a janela se estiver minimizada (quando clicado na lista)
|
||||||
|
if !start_minimized {
|
||||||
|
crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label);
|
||||||
|
let _ = set_chat_minimized_unlocked(app, ticket_id, false);
|
||||||
|
}
|
||||||
|
crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label);
|
||||||
|
if !start_minimized {
|
||||||
|
set_chat_window_state(&label, false);
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -960,7 +1214,17 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
|
||||||
// Usar query param ao inves de path para compatibilidade com SPA
|
// 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);
|
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
|
||||||
|
|
||||||
WebviewWindowBuilder::new(
|
crate::log_info!(
|
||||||
|
"[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}",
|
||||||
|
label,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
url_path
|
||||||
|
);
|
||||||
|
|
||||||
|
let window = WebviewWindowBuilder::new(
|
||||||
app,
|
app,
|
||||||
&label,
|
&label,
|
||||||
WebviewUrl::App(url_path.into()),
|
WebviewUrl::App(url_path.into()),
|
||||||
|
|
@ -972,46 +1236,64 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
|
||||||
.decorations(false) // Sem decoracoes nativas - usa header customizado
|
.decorations(false) // Sem decoracoes nativas - usa header customizado
|
||||||
.transparent(true) // Permite fundo transparente
|
.transparent(true) // Permite fundo transparente
|
||||||
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
|
.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)
|
.always_on_top(true)
|
||||||
.skip_taskbar(true)
|
.skip_taskbar(true)
|
||||||
.focused(true)
|
.focused(!start_minimized)
|
||||||
.visible(true)
|
.visible(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.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.
|
// Reaplica layout/posicao logo apos criar a janela.
|
||||||
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
|
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
|
||||||
let _ = set_chat_minimized(app, ticket_id, start_minimized);
|
let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized);
|
||||||
|
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized);
|
||||||
|
|
||||||
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
|
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
||||||
open_chat_window_internal(app, ticket_id, ticket_ref)
|
// Quando chamado explicitamente (ex: clique no hub), abre expandida
|
||||||
|
open_chat_window_internal(app, ticket_id, ticket_ref, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
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);
|
let label = format!("chat-{}", ticket_id);
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
window.close().map_err(|e| e.to_string())?;
|
window.close().map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
clear_chat_window_state(&label);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
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);
|
let label = format!("chat-{}", ticket_id);
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
window.hide().map_err(|e| e.to_string())?;
|
window.hide().map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
set_chat_window_state(&label, true);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
|
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
|
||||||
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
||||||
let label = format!("chat-{}", ticket_id);
|
let label = format!("chat-{}", ticket_id);
|
||||||
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?;
|
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)
|
// Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1)
|
||||||
let (width, height) = if minimized {
|
let (width, height) = if minimized {
|
||||||
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
|
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
|
||||||
|
|
@ -1023,9 +1305,125 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
|
||||||
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
||||||
|
|
||||||
// Aplicar novo tamanho e posicao
|
// 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())?;
|
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())?;
|
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
|
||||||
|
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized);
|
||||||
|
|
||||||
|
set_chat_window_state(&label, minimized);
|
||||||
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ mod agent;
|
||||||
mod chat;
|
mod chat;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod service_client;
|
||||||
mod usb_control;
|
mod usb_control;
|
||||||
|
|
||||||
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
||||||
|
|
@ -68,21 +70,21 @@ pub fn log_agent(level: &str, message: &str) {
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! log_info {
|
macro_rules! log_info {
|
||||||
($($arg:tt)*) => {
|
($($arg:tt)*) => {
|
||||||
$crate::log_agent("INFO", &format!($($arg)*))
|
$crate::log_agent("INFO", format!($($arg)*).as_str())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! log_error {
|
macro_rules! log_error {
|
||||||
($($arg:tt)*) => {
|
($($arg:tt)*) => {
|
||||||
$crate::log_agent("ERROR", &format!($($arg)*))
|
$crate::log_agent("ERROR", format!($($arg)*).as_str())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! log_warn {
|
macro_rules! log_warn {
|
||||||
($($arg:tt)*) => {
|
($($arg:tt)*) => {
|
||||||
$crate::log_agent("WARN", &format!($($arg)*))
|
$crate::log_agent("WARN", format!($($arg)*).as_str())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,6 +191,32 @@ fn run_rustdesk_ensure(
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
machine_id: Option<String>,
|
machine_id: Option<String>,
|
||||||
) -> Result<RustdeskProvisioningResult, String> {
|
) -> Result<RustdeskProvisioningResult, String> {
|
||||||
|
// Tenta usar o servico primeiro (sem UAC)
|
||||||
|
if service_client::is_service_available() {
|
||||||
|
log_info!("Usando Raven Service para provisionar RustDesk");
|
||||||
|
match service_client::provision_rustdesk(
|
||||||
|
config_string.as_deref(),
|
||||||
|
password.as_deref(),
|
||||||
|
machine_id.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(result) => {
|
||||||
|
return Ok(RustdeskProvisioningResult {
|
||||||
|
id: result.id,
|
||||||
|
password: result.password,
|
||||||
|
installed_version: result.installed_version,
|
||||||
|
updated: result.updated,
|
||||||
|
last_provisioned_at: result.last_provisioned_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_warn!("Falha ao usar servico para RustDesk: {e}");
|
||||||
|
// Continua para fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: chamada direta (pode pedir UAC)
|
||||||
|
log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)");
|
||||||
rustdesk::ensure_rustdesk(
|
rustdesk::ensure_rustdesk(
|
||||||
config_string.as_deref(),
|
config_string.as_deref(),
|
||||||
password.as_deref(),
|
password.as_deref(),
|
||||||
|
|
@ -208,14 +236,50 @@ fn run_rustdesk_ensure(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
|
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
|
||||||
let policy_enum = UsbPolicy::from_str(&policy)
|
// Valida a politica primeiro
|
||||||
|
let _policy_enum = UsbPolicy::from_str(&policy)
|
||||||
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
|
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
|
||||||
|
|
||||||
usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string())
|
// Tenta usar o servico primeiro (sem UAC)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if service_client::is_service_available() {
|
||||||
|
log_info!("Usando Raven Service para aplicar politica USB: {}", policy);
|
||||||
|
match service_client::apply_usb_policy(&policy) {
|
||||||
|
Ok(result) => {
|
||||||
|
return Ok(UsbPolicyResult {
|
||||||
|
success: result.success,
|
||||||
|
policy: result.policy,
|
||||||
|
error: result.error,
|
||||||
|
applied_at: result.applied_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_warn!("Falha ao usar servico para USB policy: {e}");
|
||||||
|
// Continua para fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: chamada direta (pode pedir UAC)
|
||||||
|
log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)");
|
||||||
|
usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_usb_policy() -> Result<String, String> {
|
fn get_usb_policy() -> Result<String, String> {
|
||||||
|
// Tenta usar o servico primeiro
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if service_client::is_service_available() {
|
||||||
|
match service_client::get_usb_policy() {
|
||||||
|
Ok(policy) => return Ok(policy),
|
||||||
|
Err(e) => {
|
||||||
|
log_warn!("Falha ao obter USB policy via servico: {e}");
|
||||||
|
// Continua para fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: leitura direta (nao precisa elevacao para ler)
|
||||||
usb_control::get_current_policy()
|
usb_control::get_current_policy()
|
||||||
.map(|p| p.as_str().to_string())
|
.map(|p| p.as_str().to_string())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|
@ -346,8 +410,17 @@ async fn upload_chat_file(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
|
async fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
|
||||||
chat::open_chat_window(&app, &ticket_id, ticket_ref)
|
log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let ticket_id_for_task = ticket_id.clone();
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
chat::open_chat_window(&app_handle, &ticket_id_for_task, ticket_ref)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Falha ao abrir chat (join): {err}"))?;
|
||||||
|
log_info!("[CMD] open_chat_window result: {:?}", result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -365,6 +438,26 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
|
||||||
chat::set_chat_minimized(&app, &ticket_id, minimized)
|
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://)
|
// Handler de Deep Link (raven://)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -452,6 +545,14 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_deep_link::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| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
|
@ -481,7 +582,7 @@ pub fn run() {
|
||||||
{
|
{
|
||||||
let start_in_background = std::env::args().any(|arg| arg == "--background");
|
let start_in_background = std::env::args().any(|arg| arg == "--background");
|
||||||
setup_raven_autostart();
|
setup_raven_autostart();
|
||||||
setup_tray(&app.handle())?;
|
setup_tray(app.handle())?;
|
||||||
if start_in_background {
|
if start_in_background {
|
||||||
if let Some(win) = app.get_webview_window("main") {
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
let _ = win.hide();
|
let _ = win.hide();
|
||||||
|
|
@ -526,7 +627,11 @@ pub fn run() {
|
||||||
open_chat_window,
|
open_chat_window,
|
||||||
close_chat_window,
|
close_chat_window,
|
||||||
minimize_chat_window,
|
minimize_chat_window,
|
||||||
set_chat_minimized
|
set_chat_minimized,
|
||||||
|
// Hub commands
|
||||||
|
open_hub_window,
|
||||||
|
close_hub_window,
|
||||||
|
set_hub_minimized
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
@ -608,7 +713,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||||
// Abrir janela de chat se houver sessao ativa
|
// Abrir janela de chat se houver sessao ativa
|
||||||
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
||||||
let sessions = chat_runtime.get_sessions();
|
let sessions = chat_runtime.get_sessions();
|
||||||
if let Some(session) = sessions.first() {
|
if sessions.len() > 1 {
|
||||||
|
// Multiplas sessoes - abrir hub
|
||||||
|
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
|
||||||
|
log_error!("Falha ao abrir hub de chat: {e}");
|
||||||
|
}
|
||||||
|
} else if let Some(session) = sessions.first() {
|
||||||
|
// Uma sessao - abrir diretamente
|
||||||
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
|
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}");
|
log_error!("Falha ao abrir janela de chat: {e}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
#![cfg(target_os = "windows")]
|
|
||||||
|
|
||||||
use crate::RustdeskProvisioningResult;
|
use crate::RustdeskProvisioningResult;
|
||||||
use chrono::{Local, Utc};
|
use chrono::{Local, Utc};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
@ -30,7 +28,9 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\
|
||||||
const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config";
|
const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config";
|
||||||
const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados";
|
const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados";
|
||||||
const MACHINE_STORE_FILENAME: &str = "machine-agent.json";
|
const MACHINE_STORE_FILENAME: &str = "machine-agent.json";
|
||||||
|
#[allow(dead_code)]
|
||||||
const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag";
|
const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag";
|
||||||
|
#[allow(dead_code)]
|
||||||
const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt";
|
const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt";
|
||||||
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
|
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
|
||||||
const SECURITY_APPROVE_MODE_VALUE: &str = "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) {
|
match set_custom_id(exe_path, value) {
|
||||||
Ok(custom) => {
|
Ok(custom) => {
|
||||||
log_event(&format!("ID determinístico definido: {custom}"));
|
log_event(format!("ID determinístico definido: {custom}"));
|
||||||
Some(custom)
|
Some(custom)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log_event(&format!("Falha ao definir ID determinístico: {error}"));
|
log_event(format!("Falha ao definir ID determinístico: {error}"));
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ pub fn ensure_rustdesk(
|
||||||
log_event("Iniciando preparo do RustDesk");
|
log_event("Iniciando preparo do RustDesk");
|
||||||
|
|
||||||
if let Err(error) = ensure_service_profiles_writable_preflight() {
|
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."
|
"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
|
// Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece
|
||||||
let preserved_remote_id = read_remote_id_from_profiles();
|
let preserved_remote_id = read_remote_id_from_profiles();
|
||||||
if let Some(ref id) = preserved_remote_id {
|
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();
|
let exe_path = detect_executable_path();
|
||||||
|
|
@ -129,7 +129,7 @@ pub fn ensure_rustdesk(
|
||||||
|
|
||||||
match stop_rustdesk_processes() {
|
match stop_rustdesk_processes() {
|
||||||
Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"),
|
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})"
|
"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 {
|
if freshly_installed {
|
||||||
match purge_existing_rustdesk_profiles() {
|
match purge_existing_rustdesk_profiles() {
|
||||||
Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"),
|
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})"
|
"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 trimmed.is_empty() { None } else { Some(trimmed) }
|
||||||
}) {
|
}) {
|
||||||
if let Err(error) = run_with_args(&exe_path, &["--config", value]) {
|
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 {
|
} else {
|
||||||
log_event("Configuração aplicada via --config");
|
log_event("Configuração aplicada via --config");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let config_path = write_config_files()?;
|
let config_path = write_config_files()?;
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Arquivo de configuração atualizado em {}",
|
"Arquivo de configuração atualizado em {}",
|
||||||
config_path.display()
|
config_path.display()
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Err(error) = apply_config(&exe_path, &config_path) {
|
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 {
|
} else {
|
||||||
log_event("Configuração aplicada via CLI");
|
log_event("Configuração aplicada via CLI");
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +176,7 @@ pub fn ensure_rustdesk(
|
||||||
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
||||||
|
|
||||||
if let Err(error) = set_password(&exe_path, &password) {
|
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 {
|
} else {
|
||||||
log_event("Senha padrão definida com sucesso");
|
log_event("Senha padrão definida com sucesso");
|
||||||
log_event("Aplicando senha nos perfis do RustDesk");
|
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_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk");
|
||||||
log_password_replication(&password);
|
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() {
|
match propagate_password_profile() {
|
||||||
Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"),
|
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() {
|
match replicate_password_artifacts() {
|
||||||
Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"),
|
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() {
|
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
|
// Isso garante que reinstalar o Raven nao muda o ID do RustDesk
|
||||||
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
|
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
|
||||||
if !freshly_installed {
|
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())
|
Some(existing_id.clone())
|
||||||
} else {
|
} else {
|
||||||
// Instalacao fresca - define novo ID baseado no machine_id
|
// 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) {
|
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 {
|
} else {
|
||||||
log_event("Serviço RustDesk reiniciado/run ativo");
|
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) {
|
let reported_id = match query_id_with_retries(&exe_path, 5) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
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()) {
|
match read_remote_id_from_profiles().or_else(|| custom_id.clone()) {
|
||||||
Some(value) => {
|
Some(value) => {
|
||||||
log_event(&format!("ID obtido via arquivos de perfil: {value}"));
|
log_event(format!("ID obtido via arquivos de perfil: {value}"));
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
None => return Err(error),
|
None => return Err(error),
|
||||||
|
|
@ -242,7 +242,7 @@ pub fn ensure_rustdesk(
|
||||||
|
|
||||||
if let Some(expected) = custom_id.as_ref() {
|
if let Some(expected) = custom_id.as_ref() {
|
||||||
if expected != &reported_id {
|
if expected != &reported_id {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico"
|
"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(_) => match query_id_with_retries(&exe_path, 3) {
|
||||||
Ok(rechecked) => {
|
Ok(rechecked) => {
|
||||||
if &rechecked == expected {
|
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;
|
final_id = rechecked;
|
||||||
enforced = true;
|
enforced = true;
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado"
|
"ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado"
|
||||||
));
|
));
|
||||||
final_id = rechecked;
|
final_id = rechecked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})"
|
"Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})"
|
||||||
));
|
));
|
||||||
final_id = reported_id.clone();
|
final_id = reported_id.clone();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})"
|
"Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})"
|
||||||
));
|
));
|
||||||
final_id = reported_id.clone();
|
final_id = reported_id.clone();
|
||||||
|
|
@ -308,7 +308,7 @@ pub fn ensure_rustdesk(
|
||||||
"lastError": serde_json::Value::Null
|
"lastError": serde_json::Value::Null
|
||||||
});
|
});
|
||||||
if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) {
|
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 {
|
} else {
|
||||||
log_event("Dados do RustDesk salvos no machine-agent.json");
|
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
|
// Sincroniza com o backend imediatamente apos provisionar
|
||||||
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
|
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
|
||||||
if let Err(error) = sync_remote_access_with_backend(&result) {
|
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 {
|
} else {
|
||||||
log_event("Acesso remoto sincronizado com backend");
|
log_event("Acesso remoto sincronizado com backend");
|
||||||
// Atualiza lastSyncedAt no store
|
// Atualiza lastSyncedAt no store
|
||||||
|
|
@ -330,13 +330,13 @@ pub fn ensure_rustdesk(
|
||||||
"lastError": serde_json::Value::Null
|
"lastError": serde_json::Value::Null
|
||||||
});
|
});
|
||||||
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
|
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 {
|
} else {
|
||||||
log_event("lastSyncedAt atualizado com sucesso");
|
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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
@ -403,7 +403,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
||||||
let config_contents = build_config_contents();
|
let config_contents = build_config_contents();
|
||||||
let main_path = program_data_config_dir().join("RustDesk2.toml");
|
let main_path = program_data_config_dir().join("RustDesk2.toml");
|
||||||
write_file(&main_path, &config_contents)?;
|
write_file(&main_path, &config_contents)?;
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Config principal gravada em {}",
|
"Config principal gravada em {}",
|
||||||
main_path.display()
|
main_path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -412,7 +412,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
||||||
for service_dir in service_profile_dirs() {
|
for service_dir in service_profile_dirs() {
|
||||||
let service_profile = service_dir.join("RustDesk2.toml");
|
let service_profile = service_dir.join("RustDesk2.toml");
|
||||||
if let Err(error) = write_file(&service_profile, &config_contents) {
|
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}",
|
"Falha ao gravar config no perfil do serviço ({}): {error}",
|
||||||
service_profile.display()
|
service_profile.display()
|
||||||
));
|
));
|
||||||
|
|
@ -421,7 +421,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
||||||
|
|
||||||
if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") {
|
if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") {
|
||||||
if let Err(error) = write_file(&appdata_path, &config_contents) {
|
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}"
|
"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)?;
|
ensure_service_installed(exe_path)?;
|
||||||
|
|
||||||
if let Err(error) = configure_service_startup() {
|
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}"
|
"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_with_args(exe_path, &["--install-service"]);
|
||||||
let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]);
|
let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]);
|
||||||
if let Err(error) = start_sequence() {
|
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}"
|
"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 {
|
for path in startup_paths {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
match fs::remove_file(&path) {
|
match fs::remove_file(&path) {
|
||||||
Ok(_) => log_event(&format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
|
Ok(_) => log_event(format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
|
||||||
Err(error) => log_event(&format!(
|
Err(error) => log_event(format!(
|
||||||
"Falha ao remover atalho de inicialização do RustDesk ({}): {}",
|
"Falha ao remover atalho de inicialização do RustDesk ({}): {}",
|
||||||
path.display(),
|
path.display(),
|
||||||
error
|
error
|
||||||
|
|
@ -650,7 +650,7 @@ fn remove_rustdesk_autorun_artifacts() {
|
||||||
.status();
|
.status();
|
||||||
if let Ok(code) = status {
|
if let Ok(code) = status {
|
||||||
if code.success() {
|
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> {
|
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
|
||||||
if let Err(error) = try_stop_service() {
|
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}"
|
"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() {
|
for dir in remote_id_directories() {
|
||||||
let path = dir.join("RustDesk_local.toml");
|
let path = dir.join("RustDesk_local.toml");
|
||||||
match write_remote_id_value(&path, id) {
|
match write_remote_id_value(&path, id) {
|
||||||
Ok(_) => log_event(&format!(
|
Ok(_) => log_event(format!(
|
||||||
"remote_id atualizado para {} em {}",
|
"remote_id atualizado para {} em {}",
|
||||||
id,
|
id,
|
||||||
path.display()
|
path.display()
|
||||||
)),
|
)),
|
||||||
Err(error) => log_event(&format!(
|
Err(error) => log_event(format!(
|
||||||
"Falha ao atualizar remote_id em {}: {error}",
|
"Falha ao atualizar remote_id em {}: {error}",
|
||||||
path.display()
|
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) {
|
if let Err(error) = write_toml_kv(&password_path, "password", secret) {
|
||||||
errors.push(format!("{} -> {}", password_path.display(), error));
|
errors.push(format!("{} -> {}", password_path.display(), error));
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Senha escrita via fallback em {}",
|
"Senha escrita via fallback em {}",
|
||||||
password_path.display()
|
password_path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -829,12 +829,12 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
|
||||||
|
|
||||||
let local_path = dir.join("RustDesk_local.toml");
|
let local_path = dir.join("RustDesk_local.toml");
|
||||||
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
|
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}",
|
"Falha ao ajustar verification-method em {}: {error}",
|
||||||
local_path.display()
|
local_path.display()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"verification-method atualizado para {} em {}",
|
"verification-method atualizado para {} em {}",
|
||||||
SECURITY_VERIFICATION_VALUE,
|
SECURITY_VERIFICATION_VALUE,
|
||||||
local_path.display()
|
local_path.display()
|
||||||
|
|
@ -843,19 +843,19 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
|
||||||
|
|
||||||
let rustdesk2_path = dir.join("RustDesk2.toml");
|
let rustdesk2_path = dir.join("RustDesk2.toml");
|
||||||
if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) {
|
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}",
|
"Falha ao ajustar flags no RustDesk2.toml em {}: {error}",
|
||||||
rustdesk2_path.display()
|
rustdesk2_path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
|
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}",
|
"Falha ao ajustar approve-mode em {}: {error}",
|
||||||
local_path.display()
|
local_path.display()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"approve-mode atualizado para {} em {}",
|
"approve-mode atualizado para {} em {}",
|
||||||
SECURITY_APPROVE_MODE_VALUE,
|
SECURITY_APPROVE_MODE_VALUE,
|
||||||
local_path.display()
|
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) {
|
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
|
||||||
errors.push(format!("{} -> {}", local_path.display(), error));
|
errors.push(format!("{} -> {}", local_path.display(), error));
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"verification-method atualizado para {} em {}",
|
"verification-method atualizado para {} em {}",
|
||||||
SECURITY_VERIFICATION_VALUE,
|
SECURITY_VERIFICATION_VALUE,
|
||||||
local_path.display()
|
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) {
|
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
|
||||||
errors.push(format!("{} -> {}", local_path.display(), error));
|
errors.push(format!("{} -> {}", local_path.display(), error));
|
||||||
} else {
|
} else {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"approve-mode atualizado para {} em {}",
|
"approve-mode atualizado para {} em {}",
|
||||||
SECURITY_APPROVE_MODE_VALUE,
|
SECURITY_APPROVE_MODE_VALUE,
|
||||||
local_path.display()
|
local_path.display()
|
||||||
|
|
@ -921,7 +921,7 @@ fn propagate_password_profile() -> io::Result<bool> {
|
||||||
if !src_path.exists() {
|
if !src_path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Copiando {} para ProgramData/serviços",
|
"Copiando {} para ProgramData/serviços",
|
||||||
src_path.display()
|
src_path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -929,7 +929,7 @@ fn propagate_password_profile() -> io::Result<bool> {
|
||||||
for dest_root in propagation_destinations() {
|
for dest_root in propagation_destinations() {
|
||||||
let target_path = dest_root.join(filename);
|
let target_path = dest_root.join(filename);
|
||||||
copy_overwrite(&src_path, &target_path)?;
|
copy_overwrite(&src_path, &target_path)?;
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"{} propagado para {}",
|
"{} propagado para {}",
|
||||||
filename,
|
filename,
|
||||||
target_path.display()
|
target_path.display()
|
||||||
|
|
@ -969,7 +969,7 @@ fn replicate_password_artifacts() -> io::Result<()> {
|
||||||
|
|
||||||
let target_path = dest.join(name);
|
let target_path = dest.join(name);
|
||||||
copy_overwrite(&source_path, &target_path)?;
|
copy_overwrite(&source_path, &target_path)?;
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Artefato de senha {name} replicado para {}",
|
"Artefato de senha {name} replicado para {}",
|
||||||
target_path.display()
|
target_path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -981,13 +981,11 @@ fn replicate_password_artifacts() -> io::Result<()> {
|
||||||
|
|
||||||
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
let mut cleaned_any = false;
|
|
||||||
|
|
||||||
for dir in remote_id_directories() {
|
for dir in remote_id_directories() {
|
||||||
match purge_config_dir(&dir) {
|
match purge_config_dir(&dir) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
cleaned_any = true;
|
log_event(format!(
|
||||||
log_event(&format!(
|
|
||||||
"Perfis antigos removidos em {}",
|
"Perfis antigos removidos em {}",
|
||||||
dir.display()
|
dir.display()
|
||||||
));
|
));
|
||||||
|
|
@ -997,9 +995,7 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cleaned_any {
|
if errors.is_empty() {
|
||||||
Ok(())
|
|
||||||
} else if errors.is_empty() {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(errors.join(" | "))
|
Err(errors.join(" | "))
|
||||||
|
|
@ -1030,6 +1026,7 @@ fn purge_config_dir(dir: &Path) -> Result<bool, io::Error> {
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn run_powershell_elevated(script: &str) -> Result<(), String> {
|
fn run_powershell_elevated(script: &str) -> Result<(), String> {
|
||||||
let temp_dir = env::temp_dir();
|
let temp_dir = env::temp_dir();
|
||||||
let payload = temp_dir.join("raven_payload.ps1");
|
let payload = temp_dir.join("raven_payload.ps1");
|
||||||
|
|
@ -1077,6 +1074,7 @@ exit $process.ExitCode
|
||||||
Err(format!("elevated ps exit {:?}", status.code()))
|
Err(format!("elevated ps exit {:?}", status.code()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn fix_profile_acl(target: &Path) -> Result<(), String> {
|
fn fix_profile_acl(target: &Path) -> Result<(), String> {
|
||||||
let target_str = target.display().to_string();
|
let target_str = target.display().to_string();
|
||||||
let transcript = env::temp_dir().join("raven_acl_ps.log");
|
let transcript = env::temp_dir().join("raven_acl_ps.log");
|
||||||
|
|
@ -1111,7 +1109,7 @@ try {{
|
||||||
let result = run_powershell_elevated(&script);
|
let result = run_powershell_elevated(&script);
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
if let Ok(content) = fs::read_to_string(&transcript) {
|
if let Ok(content) = fs::read_to_string(&transcript) {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"ACL transcript para {}:\n{}",
|
"ACL transcript para {}:\n{}",
|
||||||
target.display(), content
|
target.display(), content
|
||||||
));
|
));
|
||||||
|
|
@ -1122,6 +1120,9 @@ try {{
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
|
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();
|
let mut blocked_dirs = Vec::new();
|
||||||
for dir in service_profile_dirs() {
|
for dir in service_profile_dirs() {
|
||||||
if !can_write_dir(&dir) {
|
if !can_write_dir(&dir) {
|
||||||
|
|
@ -1133,53 +1134,46 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_acl_unlock_flag() {
|
// Apenas logamos aviso - o serviço RavenService deve lidar com permissões
|
||||||
log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL");
|
log_event(format!(
|
||||||
} else {
|
"Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.",
|
||||||
log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)");
|
blocked_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()
|
||||||
}
|
));
|
||||||
|
|
||||||
let mut last_error: Option<String> = None;
|
// Retornamos Ok para não bloquear o fluxo
|
||||||
for dir in blocked_dirs.iter() {
|
// O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios
|
||||||
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(())
|
Ok(())
|
||||||
} else {
|
|
||||||
Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_service_elevated() -> Result<(), String> {
|
fn stop_service_elevated() -> Result<(), String> {
|
||||||
let script = r#"
|
// Tentamos parar o serviço RustDesk sem elevação
|
||||||
$ErrorActionPreference='Stop'
|
// Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso
|
||||||
$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
|
// Não usamos elevação para evitar UAC adicional
|
||||||
if ($service -and $service.Status -ne 'Stopped') {
|
let output = Command::new("sc")
|
||||||
Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop
|
.args(["stop", "RustDesk"])
|
||||||
$service.WaitForStatus('Stopped','00:00:10')
|
.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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"#;
|
|
||||||
run_powershell_elevated(script)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_write_dir(dir: &Path) -> bool {
|
fn can_write_dir(dir: &Path) -> bool {
|
||||||
|
|
@ -1339,21 +1333,21 @@ fn log_password_replication(secret: &str) {
|
||||||
fn log_password_match(path: &Path, secret: &str) {
|
fn log_password_match(path: &Path, secret: &str) {
|
||||||
match read_password_from_file(path) {
|
match read_password_from_file(path) {
|
||||||
Some(value) if value == secret => {
|
Some(value) if value == secret => {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Senha confirmada em {} ({})",
|
"Senha confirmada em {} ({})",
|
||||||
path.display(),
|
path.display(),
|
||||||
mask_secret(&value)
|
mask_secret(&value)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(value) => {
|
Some(value) => {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Aviso: senha divergente ({}) em {}",
|
"Aviso: senha divergente ({}) em {}",
|
||||||
mask_secret(&value),
|
mask_secret(&value),
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
log_event(&format!(
|
log_event(format!(
|
||||||
"Aviso: chave 'password' não encontrada em {}",
|
"Aviso: chave 'password' não encontrada em {}",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -1469,21 +1463,24 @@ fn write_machine_store_object(map: JsonMap<String, JsonValue>) -> Result<(), Str
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> {
|
fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> {
|
||||||
let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new);
|
let mut map = read_machine_store_object().unwrap_or_default();
|
||||||
map.insert(key.to_string(), value);
|
map.insert(key.to_string(), value);
|
||||||
write_machine_store_object(map)
|
write_machine_store_object(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn machine_store_key_exists(key: &str) -> bool {
|
fn machine_store_key_exists(key: &str) -> bool {
|
||||||
read_machine_store_object()
|
read_machine_store_object()
|
||||||
.map(|map| map.contains_key(key))
|
.map(|map| map.contains_key(key))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn acl_flag_file_path() -> Option<PathBuf> {
|
fn acl_flag_file_path() -> Option<PathBuf> {
|
||||||
raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME))
|
raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn has_acl_unlock_flag() -> bool {
|
fn has_acl_unlock_flag() -> bool {
|
||||||
if let Some(flag) = acl_flag_file_path() {
|
if let Some(flag) = acl_flag_file_path() {
|
||||||
if flag.exists() {
|
if flag.exists() {
|
||||||
|
|
@ -1493,6 +1490,7 @@ fn has_acl_unlock_flag() -> bool {
|
||||||
machine_store_key_exists(RUSTDESK_ACL_STORE_KEY)
|
machine_store_key_exists(RUSTDESK_ACL_STORE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn mark_acl_unlock_flag() {
|
fn mark_acl_unlock_flag() {
|
||||||
let timestamp = Utc::now().timestamp_millis();
|
let timestamp = Utc::now().timestamp_millis();
|
||||||
if let Some(flag_path) = acl_flag_file_path() {
|
if let Some(flag_path) = acl_flag_file_path() {
|
||||||
|
|
@ -1500,7 +1498,7 @@ fn mark_acl_unlock_flag() {
|
||||||
let _ = fs::create_dir_all(parent);
|
let _ = fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
if let Err(error) = fs::write(&flag_path, timestamp.to_string()) {
|
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}",
|
"Falha ao gravar flag de ACL em {}: {error}",
|
||||||
flag_path.display()
|
flag_path.display()
|
||||||
));
|
));
|
||||||
|
|
@ -1508,7 +1506,7 @@ fn mark_acl_unlock_flag() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) {
|
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}"
|
"Falha ao registrar flag de ACL no machine-agent: {error}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1547,7 +1545,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("https://tickets.esdrasrenan.com.br");
|
.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
|
// Monta payload conforme schema esperado pelo backend
|
||||||
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
|
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
|
||||||
|
|
@ -1575,13 +1573,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
|
||||||
.send()?;
|
.send()?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().unwrap_or_default();
|
let body = response.text().unwrap_or_default();
|
||||||
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
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 {
|
Err(RustdeskError::CommandFailed {
|
||||||
command: "sync_remote_access".to_string(),
|
command: "sync_remote_access".to_string(),
|
||||||
status: Some(status.as_u16() as i32)
|
status: Some(status.as_u16() as i32)
|
||||||
|
|
|
||||||
244
apps/desktop/src-tauri/src/service_client.rs
Normal file
244
apps/desktop/src-tauri/src/service_client.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
//! Cliente IPC para comunicacao com o Raven Service
|
||||||
|
//!
|
||||||
|
//! Este modulo permite que o app Tauri se comunique com o Raven Service
|
||||||
|
//! via Named Pipes para executar operacoes privilegiadas.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::time::Duration;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ServiceClientError {
|
||||||
|
#[error("Servico nao disponivel: {0}")]
|
||||||
|
ServiceUnavailable(String),
|
||||||
|
|
||||||
|
#[error("Erro de comunicacao: {0}")]
|
||||||
|
CommunicationError(String),
|
||||||
|
|
||||||
|
#[error("Erro de serializacao: {0}")]
|
||||||
|
SerializationError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Erro do servico: {message} (code: {code})")]
|
||||||
|
ServiceError { code: i32, message: String },
|
||||||
|
|
||||||
|
#[error("Timeout aguardando resposta")]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Request {
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
params: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
id: String,
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
error: Option<ErrorResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
code: i32,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tipos de Resultado
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UsbPolicyResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub policy: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub applied_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RustdeskResult {
|
||||||
|
pub id: String,
|
||||||
|
pub password: String,
|
||||||
|
pub installed_version: Option<String>,
|
||||||
|
pub updated: bool,
|
||||||
|
pub last_provisioned_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RustdeskStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HealthCheckResult {
|
||||||
|
pub status: String,
|
||||||
|
pub service: String,
|
||||||
|
pub version: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cliente
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Verifica se o servico esta disponivel
|
||||||
|
pub fn is_service_available() -> bool {
|
||||||
|
health_check().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica saude do servico
|
||||||
|
pub fn health_check() -> Result<HealthCheckResult, ServiceClientError> {
|
||||||
|
let response = call_service("health_check", serde_json::json!({}))?;
|
||||||
|
serde_json::from_value(response).map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica politica de USB
|
||||||
|
pub fn apply_usb_policy(policy: &str) -> Result<UsbPolicyResult, ServiceClientError> {
|
||||||
|
let response = call_service(
|
||||||
|
"apply_usb_policy",
|
||||||
|
serde_json::json!({ "policy": policy }),
|
||||||
|
)?;
|
||||||
|
serde_json::from_value(response).map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtem politica de USB atual
|
||||||
|
pub fn get_usb_policy() -> Result<String, ServiceClientError> {
|
||||||
|
let response = call_service("get_usb_policy", serde_json::json!({}))?;
|
||||||
|
response
|
||||||
|
.get("policy")
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta invalida".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provisiona RustDesk
|
||||||
|
pub fn provision_rustdesk(
|
||||||
|
config: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
machine_id: Option<&str>,
|
||||||
|
) -> Result<RustdeskResult, ServiceClientError> {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"config": config,
|
||||||
|
"password": password,
|
||||||
|
"machineId": machine_id,
|
||||||
|
});
|
||||||
|
let response = call_service("provision_rustdesk", params)?;
|
||||||
|
serde_json::from_value(response).map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtem status do RustDesk
|
||||||
|
pub fn get_rustdesk_status() -> Result<RustdeskStatus, ServiceClientError> {
|
||||||
|
let response = call_service("get_rustdesk_status", serde_json::json!({}))?;
|
||||||
|
serde_json::from_value(response).map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Comunicacao IPC
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn call_service(
|
||||||
|
method: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, ServiceClientError> {
|
||||||
|
// Gera ID unico para a requisicao
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let request = Request {
|
||||||
|
id: id.clone(),
|
||||||
|
method: method.to_string(),
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serializa requisicao
|
||||||
|
let request_json = serde_json::to_string(&request)?;
|
||||||
|
|
||||||
|
// Conecta ao pipe
|
||||||
|
let mut pipe = connect_to_pipe()?;
|
||||||
|
|
||||||
|
// Envia requisicao
|
||||||
|
writeln!(pipe, "{}", request_json).map_err(|e| {
|
||||||
|
ServiceClientError::CommunicationError(format!("Erro ao enviar requisicao: {}", e))
|
||||||
|
})?;
|
||||||
|
pipe.flush().map_err(|e| {
|
||||||
|
ServiceClientError::CommunicationError(format!("Erro ao flush: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Le resposta
|
||||||
|
let mut reader = BufReader::new(pipe);
|
||||||
|
let mut response_line = String::new();
|
||||||
|
|
||||||
|
reader.read_line(&mut response_line).map_err(|e| {
|
||||||
|
ServiceClientError::CommunicationError(format!("Erro ao ler resposta: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Parse da resposta
|
||||||
|
let response: Response = serde_json::from_str(&response_line)?;
|
||||||
|
|
||||||
|
// Verifica se o ID bate
|
||||||
|
if response.id != id {
|
||||||
|
return Err(ServiceClientError::CommunicationError(
|
||||||
|
"ID de resposta nao corresponde".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica erro
|
||||||
|
if let Some(error) = response.error {
|
||||||
|
return Err(ServiceClientError::ServiceError {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retorna resultado
|
||||||
|
response
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| ServiceClientError::CommunicationError("Resposta sem resultado".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
|
||||||
|
// Tenta conectar ao pipe com retry
|
||||||
|
let mut attempts = 0;
|
||||||
|
let max_attempts = 3;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(PIPE_NAME)
|
||||||
|
{
|
||||||
|
Ok(file) => return Ok(file),
|
||||||
|
Err(e) => {
|
||||||
|
attempts += 1;
|
||||||
|
if attempts >= max_attempts {
|
||||||
|
return Err(ServiceClientError::ServiceUnavailable(format!(
|
||||||
|
"Nao foi possivel conectar ao servico apos {} tentativas: {}",
|
||||||
|
max_attempts, e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn connect_to_pipe() -> Result<std::fs::File, ServiceClientError> {
|
||||||
|
Err(ServiceClientError::ServiceUnavailable(
|
||||||
|
"Named Pipes so estao disponiveis no Windows".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -93,23 +93,11 @@ mod windows_impl {
|
||||||
applied_at: Some(now),
|
applied_at: Some(now),
|
||||||
}),
|
}),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// Tenta elevação se faltou permissão
|
// Se faltou permissão, retorna erro - o serviço deve ser usado
|
||||||
|
// Não fazemos elevação aqui para evitar UAC adicional
|
||||||
if is_permission_error(&err) {
|
if is_permission_error(&err) {
|
||||||
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
|
|
||||||
return Err(elevated_err);
|
|
||||||
}
|
|
||||||
// Revalida a policy após elevação
|
|
||||||
let current = get_current_policy()?;
|
|
||||||
if current != policy {
|
|
||||||
return Err(UsbControlError::PermissionDenied);
|
return Err(UsbControlError::PermissionDenied);
|
||||||
}
|
}
|
||||||
return Ok(UsbPolicyResult {
|
|
||||||
success: true,
|
|
||||||
policy: policy.as_str().to_string(),
|
|
||||||
error: None,
|
|
||||||
applied_at: Some(now),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -219,11 +207,9 @@ mod windows_impl {
|
||||||
|
|
||||||
key.set_value("WriteProtect", &1u32)
|
key.set_value("WriteProtect", &1u32)
|
||||||
.map_err(map_winreg_error)?;
|
.map_err(map_winreg_error)?;
|
||||||
} else {
|
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||||
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
|
||||||
let _ = key.set_value("WriteProtect", &0u32);
|
let _ = key.set_value("WriteProtect", &0u32);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +255,7 @@ mod windows_impl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
// Cria script temporário para aplicar as chaves via PowerShell elevado
|
// Cria script temporário para aplicar as chaves via PowerShell elevado
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
|
|
@ -321,7 +308,7 @@ try {{
|
||||||
policy = policy_str
|
policy = policy_str
|
||||||
);
|
);
|
||||||
|
|
||||||
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?;
|
fs::write(&script_path, script).map_err(UsbControlError::Io)?;
|
||||||
|
|
||||||
// Start-Process com RunAs para acionar UAC
|
// Start-Process com RunAs para acionar UAC
|
||||||
let arg = format!(
|
let arg = format!(
|
||||||
|
|
@ -333,7 +320,7 @@ try {{
|
||||||
.arg("-Command")
|
.arg("-Command")
|
||||||
.arg(arg)
|
.arg(arg)
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| UsbControlError::Io(e))?;
|
.map_err(UsbControlError::Io)?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(UsbControlError::PermissionDenied);
|
return Err(UsbControlError::PermissionDenied);
|
||||||
|
|
@ -362,7 +349,7 @@ try {{
|
||||||
.args(["/target:computer", "/force"])
|
.args(["/target:computer", "/force"])
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| UsbControlError::Io(e))?;
|
.map_err(UsbControlError::Io)?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
// Nao e critico se falhar, apenas log
|
// Nao e critico se falhar, apenas log
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@
|
||||||
"icons/icon.png",
|
"icons/icon.png",
|
||||||
"icons/Raven.png"
|
"icons/Raven.png"
|
||||||
],
|
],
|
||||||
|
"resources": {
|
||||||
|
"../service/target/release/raven-service.exe": "raven-service.exe"
|
||||||
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"type": "skip"
|
"type": "skip"
|
||||||
|
|
|
||||||
256
apps/desktop/src/chat/ChatHubWidget.tsx
Normal file
256
apps/desktop/src/chat/ChatHubWidget.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
/**
|
||||||
|
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
|
||||||
|
*
|
||||||
|
* Arquitetura:
|
||||||
|
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
|
||||||
|
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
|
||||||
|
* - Tauri usado apenas para gerenciamento de janelas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
|
||||||
|
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub Widget - Lista todas as sessoes de chat ativas
|
||||||
|
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
|
||||||
|
*/
|
||||||
|
export function ChatHubWidget() {
|
||||||
|
// Inicializa baseado na altura real da janela (< 100px = minimizado)
|
||||||
|
const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100)
|
||||||
|
|
||||||
|
// Convex subscription reativa
|
||||||
|
const { sessions = [], isLoading, hasToken } = useMachineSessions()
|
||||||
|
|
||||||
|
// Sincronizar estado minimizado com tamanho da janela
|
||||||
|
useEffect(() => {
|
||||||
|
const mountTime = Date.now()
|
||||||
|
const STABILIZATION_DELAY = 500
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const h = window.innerHeight
|
||||||
|
setIsMinimized(h < 100)
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", handler)
|
||||||
|
return () => window.removeEventListener("resize", handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
|
||||||
|
try {
|
||||||
|
// Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS)
|
||||||
|
await invoke("open_chat_window", { ticketId, ticketRef })
|
||||||
|
await invoke("close_hub_window")
|
||||||
|
} catch (err) {
|
||||||
|
console.error("open_chat_window FAILED:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = async () => {
|
||||||
|
setIsMinimized(true)
|
||||||
|
try {
|
||||||
|
await invoke("set_hub_minimized", { minimized: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao minimizar hub:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExpand = async () => {
|
||||||
|
try {
|
||||||
|
await invoke("set_hub_minimized", { minimized: false })
|
||||||
|
setTimeout(() => setIsMinimized(false), 100)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("set_hub_minimized FAILED:", err)
|
||||||
|
setIsMinimized(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
invoke("close_hub_window").catch((err) => {
|
||||||
|
console.error("Erro ao fechar janela do hub:", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||||
|
|
||||||
|
// Sem token
|
||||||
|
if (!hasToken) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||||
|
<span className="text-sm font-medium">Token nao configurado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">Carregando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sem sessoes ativas
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||||
|
<MessageCircle className="size-4" />
|
||||||
|
<span className="text-sm font-medium">Sem chats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimizado
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleExpand()
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{sessions.length} chat{sessions.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="size-2 rounded-full bg-emerald-400" />
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||||
|
{totalUnread > 9 ? "9+" : totalUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expandido
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
aria-label="Minimizar lista de chats"
|
||||||
|
>
|
||||||
|
<Minimize2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
aria-label="Fechar lista de chats"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de sessoes */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.sessionId}
|
||||||
|
session={session}
|
||||||
|
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionItem({
|
||||||
|
session,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
session: MachineSession
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
{/* Indicador online */}
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
Ticket #{session.ticketRef}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-xs text-slate-400">
|
||||||
|
{formatRelativeTime(session.lastActivityAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{session.agentName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge nao lidas */}
|
||||||
|
{session.unreadCount > 0 && (
|
||||||
|
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||||
|
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return "agora"
|
||||||
|
if (minutes < 60) return `${minutes}m`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}d`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
|
||||||
|
*
|
||||||
|
* Este provider inicializa o ConvexReactClient usando o token da maquina
|
||||||
|
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
|
||||||
|
*
|
||||||
|
* Arquitetura:
|
||||||
|
* - Carrega o token do Tauri Store na montagem
|
||||||
|
* - Inicializa o ConvexReactClient com a URL do Convex
|
||||||
|
* - Disponibiliza o cliente para componentes filhos via Context
|
||||||
|
* - Reconecta automaticamente quando o token muda
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||||
|
import { ConvexReactClient } from "convex/react"
|
||||||
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
|
|
||||||
|
// URL do Convex - em producao, usa o dominio personalizado
|
||||||
|
const CONVEX_URL = import.meta.env.MODE === "production"
|
||||||
|
? "https://convex.esdrasrenan.com.br"
|
||||||
|
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
|
||||||
|
|
||||||
|
type MachineAuthState = {
|
||||||
|
token: string | null
|
||||||
|
apiBaseUrl: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvexMachineContextValue = {
|
||||||
|
client: ConvexReactClient | null
|
||||||
|
machineToken: string | null
|
||||||
|
apiBaseUrl: string | null
|
||||||
|
isReady: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useConvexMachine() {
|
||||||
|
const ctx = useContext(ConvexMachineContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMachineToken() {
|
||||||
|
const { machineToken } = useConvexMachine()
|
||||||
|
return machineToken
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvexMachineProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
|
||||||
|
const [authState, setAuthState] = useState<MachineAuthState>({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [client, setClient] = useState<ConvexReactClient | null>(null)
|
||||||
|
|
||||||
|
// Funcao para carregar configuracao do Tauri Store
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getMachineStoreConfig()
|
||||||
|
|
||||||
|
if (!config.token) {
|
||||||
|
setAuthState({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: config.apiBaseUrl,
|
||||||
|
isLoading: false,
|
||||||
|
error: "Token da maquina nao encontrado",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
token: config.token,
|
||||||
|
apiBaseUrl: config.apiBaseUrl,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
setAuthState({
|
||||||
|
token: null,
|
||||||
|
apiBaseUrl: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: message || "Erro ao carregar configuracao",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carregar configuracao na montagem
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Inicializar/reinicializar cliente Convex quando token muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authState.token) {
|
||||||
|
// Limpar cliente se nao tem token
|
||||||
|
if (client) {
|
||||||
|
client.close()
|
||||||
|
setClient(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar novo cliente Convex
|
||||||
|
const newClient = new ConvexReactClient(CONVEX_URL, {
|
||||||
|
// Desabilitar retry agressivo para evitar loops infinitos
|
||||||
|
unsavedChangesWarning: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
setClient(newClient)
|
||||||
|
|
||||||
|
// Cleanup ao desmontar ou trocar token
|
||||||
|
return () => {
|
||||||
|
newClient.close()
|
||||||
|
}
|
||||||
|
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const contextValue: ConvexMachineContextValue = {
|
||||||
|
client,
|
||||||
|
machineToken: authState.token,
|
||||||
|
apiBaseUrl: authState.apiBaseUrl,
|
||||||
|
isReady: !authState.isLoading && !!client && !!authState.token,
|
||||||
|
error: authState.error,
|
||||||
|
reload: loadConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConvexMachineContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</ConvexMachineContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
apps/desktop/src/chat/audio-recorder-utils.ts
Normal file
41
apps/desktop/src/chat/audio-recorder-utils.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const AUDIO_MIME_CANDIDATES = [
|
||||||
|
"audio/webm;codecs=opus",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg;codecs=opus",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
]
|
||||||
|
|
||||||
|
const AUDIO_MIME_EXTENSION_MAP: Record<string, string> = {
|
||||||
|
"audio/webm": "webm",
|
||||||
|
"audio/ogg": "ogg",
|
||||||
|
"audio/mp4": "m4a",
|
||||||
|
"audio/mpeg": "mp3",
|
||||||
|
"audio/wav": "wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMimeType(mimeType: string) {
|
||||||
|
return mimeType.split(";")[0].trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickSupportedMimeType(isTypeSupported?: (mimeType: string) => boolean) {
|
||||||
|
const checker = isTypeSupported ?? (
|
||||||
|
typeof MediaRecorder === "undefined" ? undefined : MediaRecorder.isTypeSupported.bind(MediaRecorder)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!checker) return ""
|
||||||
|
|
||||||
|
for (const candidate of AUDIO_MIME_CANDIDATES) {
|
||||||
|
if (checker(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAudioFileName(mimeType: string, now: Date = new Date()) {
|
||||||
|
const normalized = normalizeMimeType(mimeType)
|
||||||
|
const ext = AUDIO_MIME_EXTENSION_MAP[normalized] ?? "webm"
|
||||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-")
|
||||||
|
return `audio-${timestamp}.${ext}`
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,65 @@
|
||||||
|
import { ConvexProvider } from "convex/react"
|
||||||
import { ChatWidget } from "./ChatWidget"
|
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
|
// Obter ticketId e ticketRef da URL
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const ticketId = params.get("ticketId")
|
const ticketId = params.get("ticketId")
|
||||||
const ticketRef = params.get("ticketRef")
|
const ticketRef = params.get("ticketRef")
|
||||||
|
const isHub = params.get("hub") === "true"
|
||||||
|
|
||||||
if (!ticketId) {
|
// Aguardar cliente Convex estar pronto
|
||||||
|
if (!isReady || !client) {
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p>
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||||
|
<span className="text-sm font-medium">Erro: {error}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">Conectando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo hub - lista de todas as sessoes
|
||||||
|
if (isHub || !ticketId) {
|
||||||
|
return (
|
||||||
|
<ConvexProvider client={client}>
|
||||||
|
<ChatHubWidget />
|
||||||
|
</ConvexProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo chat - conversa de um ticket especifico
|
||||||
|
return (
|
||||||
|
<ConvexProvider client={client}>
|
||||||
|
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||||
|
</ConvexProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatApp() {
|
||||||
|
return (
|
||||||
|
<ConvexMachineProvider>
|
||||||
|
<ChatAppContent />
|
||||||
|
</ConvexMachineProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ChatWidget }
|
export { ChatWidget }
|
||||||
|
export { ChatHubWidget }
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
|
||||||
253
apps/desktop/src/chat/useAudioRecorder.ts
Normal file
253
apps/desktop/src/chat/useAudioRecorder.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { buildAudioFileName, pickSupportedMimeType } from "./audio-recorder-utils"
|
||||||
|
|
||||||
|
type AudioRecorderPayload = {
|
||||||
|
file: File
|
||||||
|
durationSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioRecorderOptions = {
|
||||||
|
onAudioReady: (payload: AudioRecorderPayload) => Promise<void>
|
||||||
|
onError?: (message: string) => void
|
||||||
|
maxDurationSeconds?: number
|
||||||
|
maxFileSizeBytes?: number
|
||||||
|
audioBitsPerSecond?: number
|
||||||
|
levelBars?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioRecorderState = {
|
||||||
|
isRecording: boolean
|
||||||
|
isProcessing: boolean
|
||||||
|
durationSeconds: number
|
||||||
|
levels: number[]
|
||||||
|
startRecording: () => Promise<void>
|
||||||
|
stopRecording: () => void
|
||||||
|
cancelRecording: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioRecorder(options: AudioRecorderOptions): AudioRecorderState {
|
||||||
|
const {
|
||||||
|
onAudioReady,
|
||||||
|
onError,
|
||||||
|
maxDurationSeconds = 300,
|
||||||
|
maxFileSizeBytes = 5 * 1024 * 1024,
|
||||||
|
audioBitsPerSecond = 64000,
|
||||||
|
levelBars = 32,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
const [durationSeconds, setDurationSeconds] = useState(0)
|
||||||
|
const [levels, setLevels] = useState<number[]>(() => Array.from({ length: levelBars }, () => 0))
|
||||||
|
|
||||||
|
const durationRef = useRef(0)
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null)
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||||
|
const chunksRef = useRef<BlobPart[]>([])
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
const stopTimeoutRef = useRef<number | null>(null)
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
const cancelRef = useRef(false)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
if (stopTimeoutRef.current) {
|
||||||
|
clearTimeout(stopTimeoutRef.current)
|
||||||
|
stopTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
}
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((track) => track.stop())
|
||||||
|
streamRef.current = null
|
||||||
|
}
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
void audioContextRef.current.close()
|
||||||
|
audioContextRef.current = null
|
||||||
|
}
|
||||||
|
analyserRef.current = null
|
||||||
|
recorderRef.current = null
|
||||||
|
chunksRef.current = []
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateLevels = useCallback(() => {
|
||||||
|
const analyser = analyserRef.current
|
||||||
|
if (!analyser) return
|
||||||
|
const bufferLength = analyser.fftSize
|
||||||
|
const dataArray = new Uint8Array(bufferLength)
|
||||||
|
analyser.getByteTimeDomainData(dataArray)
|
||||||
|
|
||||||
|
const step = Math.floor(bufferLength / levelBars)
|
||||||
|
const nextLevels = Array.from({ length: levelBars }, (_, index) => {
|
||||||
|
let sum = 0
|
||||||
|
const start = index * step
|
||||||
|
const end = Math.min(start + step, bufferLength)
|
||||||
|
for (let i = start; i < end; i += 1) {
|
||||||
|
sum += Math.abs(dataArray[i] - 128)
|
||||||
|
}
|
||||||
|
const avg = sum / Math.max(1, end - start)
|
||||||
|
return Math.min(1, avg / 128)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLevels(nextLevels)
|
||||||
|
rafRef.current = requestAnimationFrame(updateLevels)
|
||||||
|
}
|
||||||
|
}, [levelBars])
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (!recorderRef.current || !isRecording) return
|
||||||
|
setIsRecording(false)
|
||||||
|
try {
|
||||||
|
recorderRef.current.stop()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao parar gravação:", error)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [cleanup, isRecording])
|
||||||
|
|
||||||
|
const cancelRecording = useCallback(() => {
|
||||||
|
cancelRef.current = true
|
||||||
|
stopRecording()
|
||||||
|
}, [stopRecording])
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
if (isRecording || isProcessing) return
|
||||||
|
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
||||||
|
onError?.("Gravação de áudio indisponível neste dispositivo.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
streamRef.current = stream
|
||||||
|
|
||||||
|
const audioContext = new AudioContext()
|
||||||
|
const analyser = audioContext.createAnalyser()
|
||||||
|
analyser.fftSize = 256
|
||||||
|
const source = audioContext.createMediaStreamSource(stream)
|
||||||
|
source.connect(analyser)
|
||||||
|
audioContextRef.current = audioContext
|
||||||
|
analyserRef.current = analyser
|
||||||
|
|
||||||
|
const mimeType = pickSupportedMimeType()
|
||||||
|
const recorderOptions: MediaRecorderOptions = mimeType
|
||||||
|
? { mimeType, audioBitsPerSecond }
|
||||||
|
: { audioBitsPerSecond }
|
||||||
|
|
||||||
|
const recorder = new MediaRecorder(stream, recorderOptions)
|
||||||
|
recorderRef.current = recorder
|
||||||
|
chunksRef.current = []
|
||||||
|
cancelRef.current = false
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onstop = async () => {
|
||||||
|
const blobType = recorder.mimeType || mimeType || "audio/webm"
|
||||||
|
const blob = new Blob(chunksRef.current, { type: blobType })
|
||||||
|
chunksRef.current = []
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
if (cancelRef.current) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLevels(Array.from({ length: levelBars }, () => 0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blob.size > maxFileSizeBytes) {
|
||||||
|
onError?.("Áudio excede o limite de 5MB. Tente gravar por menos tempo.")
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLevels(Array.from({ length: levelBars }, () => 0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = buildAudioFileName(blobType)
|
||||||
|
const file = new File([blob], fileName, { type: blobType })
|
||||||
|
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
await onAudioReady({ file, durationSeconds: durationRef.current })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Falha ao enviar áudio."
|
||||||
|
onError?.(message)
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setIsProcessing(false)
|
||||||
|
setLevels(Array.from({ length: levelBars }, () => 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.start()
|
||||||
|
durationRef.current = 0
|
||||||
|
setDurationSeconds(0)
|
||||||
|
setIsRecording(true)
|
||||||
|
updateLevels()
|
||||||
|
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
setDurationSeconds((prev) => {
|
||||||
|
const next = prev + 1
|
||||||
|
durationRef.current = next
|
||||||
|
if (next >= maxDurationSeconds) {
|
||||||
|
stopRecording()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
stopTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
stopRecording()
|
||||||
|
}, maxDurationSeconds * 1000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao iniciar gravação:", error)
|
||||||
|
onError?.("Não foi possível iniciar a gravação de áudio.")
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
audioBitsPerSecond,
|
||||||
|
cleanup,
|
||||||
|
isProcessing,
|
||||||
|
isRecording,
|
||||||
|
levelBars,
|
||||||
|
maxDurationSeconds,
|
||||||
|
maxFileSizeBytes,
|
||||||
|
onAudioReady,
|
||||||
|
onError,
|
||||||
|
stopRecording,
|
||||||
|
updateLevels,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
isProcessing,
|
||||||
|
durationSeconds,
|
||||||
|
levels,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
}
|
||||||
|
}
|
||||||
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* Hooks customizados para queries/mutations do Convex com token de maquina
|
||||||
|
*
|
||||||
|
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
|
||||||
|
* para as queries e mutations do Convex, proporcionando uma API simples
|
||||||
|
* e reativa para os componentes de chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useAction } from "convex/react"
|
||||||
|
import { api } from "@convex/_generated/api"
|
||||||
|
import type { Id } from "@convex/_generated/dataModel"
|
||||||
|
import { useMachineToken } from "./ConvexMachineProvider"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type MachineSession = {
|
||||||
|
sessionId: Id<"liveChatSessions">
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
ticketRef: number
|
||||||
|
ticketSubject: string
|
||||||
|
agentName: string
|
||||||
|
agentEmail?: string
|
||||||
|
agentAvatarUrl?: string
|
||||||
|
unreadCount: number
|
||||||
|
lastActivityAt: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineMessage = {
|
||||||
|
id: Id<"ticketChatMessages">
|
||||||
|
body: string
|
||||||
|
authorName: string
|
||||||
|
authorAvatarUrl?: string
|
||||||
|
isFromMachine: boolean
|
||||||
|
createdAt: number
|
||||||
|
attachments: Array<{
|
||||||
|
storageId: Id<"_storage">
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineMessagesResult = {
|
||||||
|
messages: MachineMessage[]
|
||||||
|
hasSession: boolean
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MachineUpdatesResult = {
|
||||||
|
hasActiveSessions: boolean
|
||||||
|
sessions: Array<{
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
ticketRef: number
|
||||||
|
unreadCount: number
|
||||||
|
lastActivityAt: number
|
||||||
|
}>
|
||||||
|
totalUnread: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HOOKS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para listar sessoes ativas da maquina
|
||||||
|
* Subscription reativa - atualiza automaticamente quando ha mudancas
|
||||||
|
*/
|
||||||
|
export function useMachineSessions() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const sessions = useQuery(
|
||||||
|
api.liveChat.listMachineSessions,
|
||||||
|
machineToken ? { machineToken } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: sessions as MachineSession[] | undefined,
|
||||||
|
isLoading: sessions === undefined && !!machineToken,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para listar mensagens de um ticket especifico
|
||||||
|
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
|
||||||
|
*/
|
||||||
|
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const result = useQuery(
|
||||||
|
api.liveChat.listMachineMessages,
|
||||||
|
machineToken && ticketId
|
||||||
|
? { machineToken, ticketId, limit: options?.limit }
|
||||||
|
: "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
|
||||||
|
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
|
||||||
|
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
|
||||||
|
isLoading: result === undefined && !!machineToken && !!ticketId,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para verificar updates (polling leve)
|
||||||
|
* Usado como fallback ou para verificar status rapidamente
|
||||||
|
*/
|
||||||
|
export function useMachineUpdates() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
|
||||||
|
const result = useQuery(
|
||||||
|
api.liveChat.checkMachineUpdates,
|
||||||
|
machineToken ? { machineToken } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
|
||||||
|
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
|
||||||
|
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
|
||||||
|
isLoading: result === undefined && !!machineToken,
|
||||||
|
hasToken: !!machineToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para enviar mensagem
|
||||||
|
*/
|
||||||
|
export function usePostMachineMessage() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const postMessage = useMutation(api.liveChat.postMachineMessage)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
body: string
|
||||||
|
attachments?: Array<{
|
||||||
|
storageId: Id<"_storage">
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
type?: string
|
||||||
|
}>
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return postMessage({
|
||||||
|
machineToken,
|
||||||
|
ticketId: args.ticketId,
|
||||||
|
body: args.body,
|
||||||
|
attachments: args.attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para marcar mensagens como lidas
|
||||||
|
*/
|
||||||
|
export function useMarkMachineMessagesRead() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
messageIds: Id<"ticketChatMessages">[]
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return markRead({
|
||||||
|
machineToken,
|
||||||
|
ticketId: args.ticketId,
|
||||||
|
messageIds: args.messageIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para gerar URL de upload
|
||||||
|
*/
|
||||||
|
export function useGenerateMachineUploadUrl() {
|
||||||
|
const machineToken = useMachineToken()
|
||||||
|
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
|
||||||
|
|
||||||
|
return async (args: {
|
||||||
|
fileName: string
|
||||||
|
fileType: string
|
||||||
|
fileSize: number
|
||||||
|
}) => {
|
||||||
|
if (!machineToken) {
|
||||||
|
throw new Error("Token da maquina nao disponivel")
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateUrl({
|
||||||
|
machineToken,
|
||||||
|
fileName: args.fileName,
|
||||||
|
fileType: args.fileType,
|
||||||
|
fileSize: args.fileSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
import { ShieldAlert, Mail } from "lucide-react"
|
import { ShieldAlert, Mail, RefreshCw } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
type DeactivationScreenProps = {
|
||||||
|
companyName?: string | null
|
||||||
|
onRetry?: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeactivationScreen({ onRetry }: DeactivationScreenProps) {
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false)
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
if (isRetrying || !onRetry) return
|
||||||
|
setIsRetrying(true)
|
||||||
|
try {
|
||||||
|
await onRetry()
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DeactivationScreen({ companyName }: { companyName?: string | null }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center bg-neutral-950 p-6">
|
<div className="fixed inset-0 z-50 grid place-items-center overflow-hidden bg-neutral-950 p-6">
|
||||||
<div className="flex w-full max-w-[720px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
<div className="flex w-full max-w-[720px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
|
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
|
||||||
<ShieldAlert className="size-4" /> Acesso bloqueado
|
<ShieldAlert className="size-4" /> Acesso bloqueado
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativado</h1>
|
||||||
<p className="max-w-md text-sm text-neutral-600">
|
<p className="max-w-md text-sm text-neutral-600">
|
||||||
Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
|
Este dispositivo foi desativado temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
|
||||||
envio de informações ficam indisponíveis.
|
envio de informações ficam indisponíveis.
|
||||||
</p>
|
</p>
|
||||||
{companyName ? (
|
|
||||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700">
|
|
||||||
{companyName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-[520px] space-y-4">
|
<div className="w-full max-w-[520px] space-y-4">
|
||||||
|
|
@ -29,12 +42,25 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<a
|
<a
|
||||||
href="mailto:suporte@rever.com.br"
|
href="mailto:suporte@rever.com.br"
|
||||||
className="mx-auto inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
|
className="inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||||
>
|
>
|
||||||
<Mail className="size-4" /> Falar com o suporte
|
<Mail className="size-4" /> Falar com o suporte
|
||||||
</a>
|
</a>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${isRetrying ? "animate-spin" : ""}`} />
|
||||||
|
{isRetrying ? "Verificando..." : "Verificar novamente"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
103
apps/desktop/src/components/MachineStateMonitor.tsx
Normal file
103
apps/desktop/src/components/MachineStateMonitor.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* MachineStateMonitor - Componente para monitorar o estado da máquina em tempo real
|
||||||
|
*
|
||||||
|
* Este componente usa uma subscription Convex para detectar mudanças no estado da máquina:
|
||||||
|
* - Quando isActive muda para false: máquina foi desativada
|
||||||
|
* - Quando hasValidToken muda para false: máquina foi resetada (tokens revogados)
|
||||||
|
*
|
||||||
|
* O componente não renderiza nada, apenas monitora e chama callbacks quando detecta mudanças.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { useQuery, ConvexProvider } from "convex/react"
|
||||||
|
import type { ConvexReactClient } from "convex/react"
|
||||||
|
import { api } from "../convex/_generated/api"
|
||||||
|
import type { Id } from "../convex/_generated/dataModel"
|
||||||
|
|
||||||
|
type MachineStateMonitorProps = {
|
||||||
|
machineId: string
|
||||||
|
onDeactivated?: () => void
|
||||||
|
onTokenRevoked?: () => void
|
||||||
|
onReactivated?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MachineStateMonitorInner({ machineId, onDeactivated, onTokenRevoked, onReactivated }: MachineStateMonitorProps) {
|
||||||
|
const machineState = useQuery(api.machines.getMachineState, {
|
||||||
|
machineId: machineId as Id<"machines">,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refs para rastrear o estado anterior e evitar chamadas duplicadas
|
||||||
|
const previousIsActive = useRef<boolean | null>(null)
|
||||||
|
const previousHasValidToken = useRef<boolean | null>(null)
|
||||||
|
const initialLoadDone = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machineState) return
|
||||||
|
|
||||||
|
// Na primeira carga, verifica estado inicial E armazena valores
|
||||||
|
if (!initialLoadDone.current) {
|
||||||
|
console.log("[MachineStateMonitor] Carga inicial", {
|
||||||
|
isActive: machineState.isActive,
|
||||||
|
hasValidToken: machineState.hasValidToken,
|
||||||
|
found: machineState.found,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Se já estiver desativado na carga inicial, chama callback
|
||||||
|
if (machineState.isActive === false) {
|
||||||
|
console.log("[MachineStateMonitor] Máquina já estava desativada")
|
||||||
|
onDeactivated?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se token já estiver inválido na carga inicial, chama callback
|
||||||
|
if (machineState.hasValidToken === false) {
|
||||||
|
console.log("[MachineStateMonitor] Token já estava revogado")
|
||||||
|
onTokenRevoked?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
previousIsActive.current = machineState.isActive
|
||||||
|
previousHasValidToken.current = machineState.hasValidToken
|
||||||
|
initialLoadDone.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta mudança de ativo para inativo
|
||||||
|
if (previousIsActive.current === true && machineState.isActive === false) {
|
||||||
|
console.log("[MachineStateMonitor] Máquina foi desativada")
|
||||||
|
onDeactivated?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta mudança de inativo para ativo (reativação)
|
||||||
|
if (previousIsActive.current === false && machineState.isActive === true) {
|
||||||
|
console.log("[MachineStateMonitor] Máquina foi reativada")
|
||||||
|
onReactivated?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta mudança de token válido para inválido
|
||||||
|
if (previousHasValidToken.current === true && machineState.hasValidToken === false) {
|
||||||
|
console.log("[MachineStateMonitor] Token foi revogado (reset)")
|
||||||
|
onTokenRevoked?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza refs
|
||||||
|
previousIsActive.current = machineState.isActive
|
||||||
|
previousHasValidToken.current = machineState.hasValidToken
|
||||||
|
}, [machineState, onDeactivated, onTokenRevoked, onReactivated])
|
||||||
|
|
||||||
|
// Este componente nao renderiza nada
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineStateMonitorWithClientProps = MachineStateMonitorProps & {
|
||||||
|
client: ConvexReactClient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper que recebe o cliente Convex e envolve o monitor com o provider
|
||||||
|
*/
|
||||||
|
export function MachineStateMonitor({ client, ...props }: MachineStateMonitorWithClientProps) {
|
||||||
|
return (
|
||||||
|
<ConvexProvider client={client}>
|
||||||
|
<MachineStateMonitorInner {...props} />
|
||||||
|
</ConvexProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as alerts from "../alerts.js";
|
||||||
|
import type * as automations from "../automations.js";
|
||||||
|
import type * as bootstrap from "../bootstrap.js";
|
||||||
|
import type * as categories from "../categories.js";
|
||||||
|
import type * as categorySlas from "../categorySlas.js";
|
||||||
|
import type * as checklistTemplates from "../checklistTemplates.js";
|
||||||
|
import type * as commentTemplates from "../commentTemplates.js";
|
||||||
|
import type * as companies from "../companies.js";
|
||||||
|
import type * as crons from "../crons.js";
|
||||||
|
import type * as dashboards from "../dashboards.js";
|
||||||
|
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||||
|
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
|
||||||
|
import type * as deviceFields from "../deviceFields.js";
|
||||||
|
import type * as devices from "../devices.js";
|
||||||
|
import type * as emprestimos from "../emprestimos.js";
|
||||||
|
import type * as fields from "../fields.js";
|
||||||
|
import type * as files from "../files.js";
|
||||||
|
import type * as incidents from "../incidents.js";
|
||||||
|
import type * as invites from "../invites.js";
|
||||||
|
import type * as liveChat from "../liveChat.js";
|
||||||
|
import type * as machines from "../machines.js";
|
||||||
|
import type * as metrics from "../metrics.js";
|
||||||
|
import type * as migrations from "../migrations.js";
|
||||||
|
import type * as ops from "../ops.js";
|
||||||
|
import type * as queues from "../queues.js";
|
||||||
|
import type * as rbac from "../rbac.js";
|
||||||
|
import type * as reports from "../reports.js";
|
||||||
|
import type * as revision from "../revision.js";
|
||||||
|
import type * as seed from "../seed.js";
|
||||||
|
import type * as slas from "../slas.js";
|
||||||
|
import type * as teams from "../teams.js";
|
||||||
|
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||||
|
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
|
||||||
|
import type * as ticketNotifications from "../ticketNotifications.js";
|
||||||
|
import type * as tickets from "../tickets.js";
|
||||||
|
import type * as usbPolicy from "../usbPolicy.js";
|
||||||
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiFromModules,
|
||||||
|
FilterApi,
|
||||||
|
FunctionReference,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
declare const fullApi: ApiFromModules<{
|
||||||
|
alerts: typeof alerts;
|
||||||
|
automations: typeof automations;
|
||||||
|
bootstrap: typeof bootstrap;
|
||||||
|
categories: typeof categories;
|
||||||
|
categorySlas: typeof categorySlas;
|
||||||
|
checklistTemplates: typeof checklistTemplates;
|
||||||
|
commentTemplates: typeof commentTemplates;
|
||||||
|
companies: typeof companies;
|
||||||
|
crons: typeof crons;
|
||||||
|
dashboards: typeof dashboards;
|
||||||
|
deviceExportTemplates: typeof deviceExportTemplates;
|
||||||
|
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||||
|
deviceFields: typeof deviceFields;
|
||||||
|
devices: typeof devices;
|
||||||
|
emprestimos: typeof emprestimos;
|
||||||
|
fields: typeof fields;
|
||||||
|
files: typeof files;
|
||||||
|
incidents: typeof incidents;
|
||||||
|
invites: typeof invites;
|
||||||
|
liveChat: typeof liveChat;
|
||||||
|
machines: typeof machines;
|
||||||
|
metrics: typeof metrics;
|
||||||
|
migrations: typeof migrations;
|
||||||
|
ops: typeof ops;
|
||||||
|
queues: typeof queues;
|
||||||
|
rbac: typeof rbac;
|
||||||
|
reports: typeof reports;
|
||||||
|
revision: typeof revision;
|
||||||
|
seed: typeof seed;
|
||||||
|
slas: typeof slas;
|
||||||
|
teams: typeof teams;
|
||||||
|
ticketFormSettings: typeof ticketFormSettings;
|
||||||
|
ticketFormTemplates: typeof ticketFormTemplates;
|
||||||
|
ticketNotifications: typeof ticketNotifications;
|
||||||
|
tickets: typeof tickets;
|
||||||
|
usbPolicy: typeof usbPolicy;
|
||||||
|
users: typeof users;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's public API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const api: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "public">
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's internal API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = internal.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const internal: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "internal">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export declare const components: {};
|
||||||
23
apps/desktop/src/convex/_generated/api.js
Normal file
23
apps/desktop/src/convex/_generated/api.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { anyApi, componentsGeneric } from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const api = anyApi;
|
||||||
|
export const internal = anyApi;
|
||||||
|
export const components = componentsGeneric();
|
||||||
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated data model types.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DataModelFromSchemaDefinition,
|
||||||
|
DocumentByName,
|
||||||
|
TableNamesInDataModel,
|
||||||
|
SystemTableNames,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { GenericId } from "convex/values";
|
||||||
|
import schema from "../schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of all of your Convex tables.
|
||||||
|
*/
|
||||||
|
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a document stored in Convex.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||||
|
DataModel,
|
||||||
|
TableName
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An identifier for a document in Convex.
|
||||||
|
*
|
||||||
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||||
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||||
|
*
|
||||||
|
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
||||||
|
*
|
||||||
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
|
* strings when type checking.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||||
|
GenericId<TableName>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type describing your Convex data model.
|
||||||
|
*
|
||||||
|
* This type includes information about what tables you have, the type of
|
||||||
|
* documents stored in those tables, and the indexes defined on them.
|
||||||
|
*
|
||||||
|
* This type is used to parameterize methods like `queryGeneric` and
|
||||||
|
* `mutationGeneric` to make them type-safe.
|
||||||
|
*/
|
||||||
|
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||||
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionBuilder,
|
||||||
|
HttpActionBuilder,
|
||||||
|
MutationBuilder,
|
||||||
|
QueryBuilder,
|
||||||
|
GenericActionCtx,
|
||||||
|
GenericMutationCtx,
|
||||||
|
GenericQueryCtx,
|
||||||
|
GenericDatabaseReader,
|
||||||
|
GenericDatabaseWriter,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const query: QueryBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const action: ActionBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export declare const httpAction: HttpActionBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex query functions.
|
||||||
|
*
|
||||||
|
* The query context is passed as the first argument to any Convex query
|
||||||
|
* function run on the server.
|
||||||
|
*
|
||||||
|
* This differs from the {@link MutationCtx} because all of the services are
|
||||||
|
* read-only.
|
||||||
|
*/
|
||||||
|
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex mutation functions.
|
||||||
|
*
|
||||||
|
* The mutation context is passed as the first argument to any Convex mutation
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex action functions.
|
||||||
|
*
|
||||||
|
* The action context is passed as the first argument to any Convex action
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from the database within Convex query functions.
|
||||||
|
*
|
||||||
|
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||||
|
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||||
|
* building a query.
|
||||||
|
*/
|
||||||
|
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from and write to the database within Convex mutation
|
||||||
|
* functions.
|
||||||
|
*
|
||||||
|
* Convex guarantees that all writes within a single mutation are
|
||||||
|
* executed atomically, so you never have to worry about partial writes leaving
|
||||||
|
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||||
|
* for the guarantees Convex provides your functions.
|
||||||
|
*/
|
||||||
|
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||||
93
apps/desktop/src/convex/_generated/server.js
Normal file
93
apps/desktop/src/convex/_generated/server.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionGeneric,
|
||||||
|
httpActionGeneric,
|
||||||
|
queryGeneric,
|
||||||
|
mutationGeneric,
|
||||||
|
internalActionGeneric,
|
||||||
|
internalMutationGeneric,
|
||||||
|
internalQueryGeneric,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const query = queryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalQuery = internalQueryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const mutation = mutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalMutation = internalMutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const action = actionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalAction = internalActionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export const httpAction = httpActionGeneric;
|
||||||
|
|
@ -6,12 +6,21 @@ import { listen } from "@tauri-apps/api/event"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
||||||
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
import { ConvexReactClient } from "convex/react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
||||||
import { cn } from "./lib/utils"
|
import { cn } from "./lib/utils"
|
||||||
import { ChatApp } from "./chat"
|
import { ChatApp } from "./chat"
|
||||||
import { DeactivationScreen } from "./components/DeactivationScreen"
|
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"
|
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 = {
|
type MachineOs = {
|
||||||
name: string
|
name: string
|
||||||
version?: string | null
|
version?: string | null
|
||||||
|
|
@ -304,7 +313,7 @@ function App() {
|
||||||
const [token, setToken] = useState<string | null>(null)
|
const [token, setToken] = useState<string | null>(null)
|
||||||
const [config, setConfig] = useState<AgentConfig | null>(null)
|
const [config, setConfig] = useState<AgentConfig | null>(null)
|
||||||
const [profile, setProfile] = useState<MachineProfile | null>(null)
|
const [profile, setProfile] = useState<MachineProfile | null>(null)
|
||||||
const [logoSrc, setLogoSrc] = useState<string>(() => `${appUrl}/logo-raven.png`)
|
const [logoSrc, setLogoSrc] = useState<string>("/logo-raven.png")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
|
@ -321,6 +330,9 @@ function App() {
|
||||||
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
|
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
|
||||||
const lastHealAtRef = useRef(0)
|
const lastHealAtRef = useRef(0)
|
||||||
|
|
||||||
|
// Cliente Convex para monitoramento em tempo real do estado da maquina
|
||||||
|
const [convexClient, setConvexClient] = useState<ConvexReactClient | null>(null)
|
||||||
|
|
||||||
const [provisioningCode, setProvisioningCode] = useState("")
|
const [provisioningCode, setProvisioningCode] = useState("")
|
||||||
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
|
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
|
||||||
const [companyName, setCompanyName] = useState("")
|
const [companyName, setCompanyName] = useState("")
|
||||||
|
|
@ -410,8 +422,15 @@ function App() {
|
||||||
status: "online",
|
status: "online",
|
||||||
intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300,
|
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) {
|
} catch (err) {
|
||||||
console.error("Falha ao reiniciar heartbeat", err)
|
console.error("Falha ao reiniciar heartbeat/chat", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextConfig
|
return nextConfig
|
||||||
|
|
@ -586,8 +605,15 @@ function App() {
|
||||||
status: "online",
|
status: "online",
|
||||||
intervalSeconds: 300,
|
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) {
|
} catch (err) {
|
||||||
console.error("Falha ao iniciar heartbeat em segundo plano", err)
|
console.error("Falha ao iniciar heartbeat/chat em segundo plano", err)
|
||||||
}
|
}
|
||||||
const payload = await res.clone().json().catch(() => null)
|
const payload = await res.clone().json().catch(() => null)
|
||||||
if (payload && typeof payload === "object" && "machine" in payload) {
|
if (payload && typeof payload === "object" && "machine" in payload) {
|
||||||
|
|
@ -679,6 +705,88 @@ useEffect(() => {
|
||||||
rustdeskInfoRef.current = rustdeskInfo
|
rustdeskInfoRef.current = rustdeskInfo
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!store || !config) return
|
if (!store || !config) return
|
||||||
|
|
@ -1249,6 +1357,10 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
|
|
||||||
const openSystem = useCallback(async () => {
|
const openSystem = useCallback(async () => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
if (!isMachineActive) {
|
||||||
|
setIsLaunchingSystem(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsLaunchingSystem(true)
|
setIsLaunchingSystem(true)
|
||||||
|
|
||||||
// Recarrega store do disco para pegar dados que o Rust salvou diretamente
|
// Recarrega store do disco para pegar dados que o Rust salvou diretamente
|
||||||
|
|
@ -1308,7 +1420,6 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
if (!currentActive) {
|
if (!currentActive) {
|
||||||
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
|
|
||||||
setIsLaunchingSystem(false)
|
setIsLaunchingSystem(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1316,14 +1427,8 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (res.status === 423) {
|
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)
|
setIsMachineActive(false)
|
||||||
setIsLaunchingSystem(false)
|
setIsLaunchingSystem(false)
|
||||||
setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Se sessão falhar, tenta identificar token inválido/expirado
|
// Se sessão falhar, tenta identificar token inválido/expirado
|
||||||
|
|
@ -1373,7 +1478,7 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
||||||
logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") })
|
logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") })
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store])
|
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive])
|
||||||
|
|
||||||
async function reprovision() {
|
async function reprovision() {
|
||||||
if (!store) return
|
if (!store) return
|
||||||
|
|
@ -1478,17 +1583,28 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
if (autoLaunchRef.current) return
|
if (autoLaunchRef.current) return
|
||||||
if (!tokenVerifiedRef.current) return
|
if (!tokenVerifiedRef.current) return
|
||||||
|
if (!isMachineActive) return // Não redireciona se a máquina estiver desativada
|
||||||
autoLaunchRef.current = true
|
autoLaunchRef.current = true
|
||||||
setIsLaunchingSystem(true)
|
setIsLaunchingSystem(true)
|
||||||
openSystem()
|
openSystem()
|
||||||
}, [token, status, config?.accessRole, openSystem, tokenValidationTick])
|
}, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive])
|
||||||
|
|
||||||
// Quando há token persistido (dispositivo já provisionado) e ainda não
|
// Quando há token persistido (dispositivo já provisionado) e ainda não
|
||||||
// disparamos o auto-launch, exibimos diretamente a tela de loading da
|
// disparamos o auto-launch, exibimos diretamente a tela de loading da
|
||||||
// plataforma para evitar piscar o card de resumo/inventário.
|
// plataforma para evitar piscar o card de resumo/inventário.
|
||||||
if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) {
|
// IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real
|
||||||
|
if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
||||||
|
{/* Monitor de estado da máquina - deve rodar mesmo durante loading */}
|
||||||
|
{token && config?.machineId && convexClient && (
|
||||||
|
<MachineStateMonitor
|
||||||
|
client={convexClient}
|
||||||
|
machineId={config.machineId}
|
||||||
|
onDeactivated={handleMachineDeactivated}
|
||||||
|
onTokenRevoked={handleTokenRevoked}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
||||||
<Loader2 className="size-6 animate-spin text-neutral-700" />
|
<Loader2 className="size-6 animate-spin text-neutral-700" />
|
||||||
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever…</p>
|
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever…</p>
|
||||||
|
|
@ -1498,11 +1614,31 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Monitor sempre ativo quando há token e machineId
|
||||||
|
const machineMonitor = token && config?.machineId && convexClient ? (
|
||||||
|
<MachineStateMonitor
|
||||||
|
client={convexClient}
|
||||||
|
machineId={config.machineId}
|
||||||
|
onDeactivated={handleMachineDeactivated}
|
||||||
|
onTokenRevoked={handleTokenRevoked}
|
||||||
|
onReactivated={handleMachineReactivated}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
// Tela de desativação (renderizada separadamente para evitar container com fundo claro)
|
||||||
|
if (token && !isMachineActive) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{machineMonitor}
|
||||||
|
<DeactivationScreen companyName={companyName} onRetry={handleRetryCheck} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
||||||
{token && !isMachineActive ? (
|
{/* Monitor de estado da maquina em tempo real via Convex */}
|
||||||
<DeactivationScreen companyName={companyName} />
|
{machineMonitor}
|
||||||
) : (
|
|
||||||
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<div className="mb-6 flex flex-col items-center gap-4 text-center">
|
<div className="mb-6 flex flex-col items-center gap-4 text-center">
|
||||||
<img
|
<img
|
||||||
|
|
@ -1510,16 +1646,23 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
alt="Logotipo Raven"
|
alt="Logotipo Raven"
|
||||||
width={160}
|
width={160}
|
||||||
height={160}
|
height={160}
|
||||||
className="h-14 w-auto md:h-16"
|
className="h-16 w-auto md:h-20"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
if (logoFallbackRef.current) return
|
if (logoFallbackRef.current) return
|
||||||
logoFallbackRef.current = true
|
logoFallbackRef.current = true
|
||||||
setLogoSrc(`${appUrl}/raven.png`)
|
setLogoSrc(`${appUrl}/logo-raven.png`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-xs text-neutral-500">Raven</span>
|
<span className="text-lg font-semibold text-neutral-900">Raven</span>
|
||||||
<span className="text-2xl font-semibold text-neutral-900">Sistema de chamados</span>
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
||||||
|
Plataforma de
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
||||||
|
Chamados
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1723,8 +1866,6 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@convex/_generated/*": ["./src/convex/_generated/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
|
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react()],
|
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`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|
|
||||||
58
bun.lock
58
bun.lock
|
|
@ -21,6 +21,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
|
@ -114,6 +115,7 @@
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"convex": "^1.31.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -512,6 +514,8 @@
|
||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-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-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=="],
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
|
|
@ -2332,6 +2336,8 @@
|
||||||
|
|
||||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
"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/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=="],
|
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
@ -2466,6 +2472,8 @@
|
||||||
|
|
||||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"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=="],
|
"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=="],
|
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
@ -2597,5 +2605,55 @@
|
||||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"wrap-ansi-cjs/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=="],
|
"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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
} from "./automationsEngine"
|
} from "./automationsEngine"
|
||||||
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
||||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
||||||
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail"
|
import type { AutomationEmailProps } from "./reactEmail"
|
||||||
import { buildBaseUrl } from "./url"
|
import { buildBaseUrl } from "./url"
|
||||||
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
|
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
|
||||||
|
|
||||||
|
|
@ -988,19 +988,38 @@ async function applyActions(
|
||||||
ctaLabel,
|
ctaLabel,
|
||||||
ctaUrl,
|
ctaUrl,
|
||||||
}
|
}
|
||||||
const html = await renderAutomationEmailHtml(emailProps)
|
|
||||||
|
|
||||||
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
|
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
emailProps: {
|
||||||
|
title: emailProps.title,
|
||||||
|
message: emailProps.message,
|
||||||
|
ticket: {
|
||||||
|
reference: emailProps.ticket.reference,
|
||||||
|
subject: emailProps.ticket.subject,
|
||||||
|
status: emailProps.ticket.status ?? null,
|
||||||
|
priority: emailProps.ticket.priority ?? null,
|
||||||
|
companyName: emailProps.ticket.companyName ?? null,
|
||||||
|
requesterName: emailProps.ticket.requesterName ?? null,
|
||||||
|
assigneeName: emailProps.ticket.assigneeName ?? null,
|
||||||
|
},
|
||||||
|
ctaLabel: emailProps.ctaLabel,
|
||||||
|
ctaUrl: emailProps.ctaUrl,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
applied.push({
|
applied.push({
|
||||||
type: "SEND_EMAIL",
|
type: "SEND_EMAIL",
|
||||||
details: {
|
details: {
|
||||||
|
recipients: to,
|
||||||
toCount: to.length,
|
toCount: to.length,
|
||||||
|
subject,
|
||||||
|
messagePreview: message.length > 100 ? `${message.slice(0, 100)}...` : message,
|
||||||
ctaTarget: effectiveTarget,
|
ctaTarget: effectiveTarget,
|
||||||
|
ctaLabel,
|
||||||
|
ctaUrl,
|
||||||
|
scheduledAt: Date.now(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,37 @@ function normalizeTemplateDescription(input: string | null | undefined) {
|
||||||
return text.length > 0 ? text : null
|
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(
|
function normalizeTemplateItems(
|
||||||
raw: Array<{ id?: string; text: string; required?: boolean }>,
|
raw: RawTemplateItem[],
|
||||||
options: { generateId?: () => string }
|
options: { generateId?: () => string }
|
||||||
) {
|
): NormalizedTemplateItem[] {
|
||||||
if (!Array.isArray(raw) || raw.length === 0) {
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
throw new ConvexError("Adicione pelo menos um item no checklist.")
|
throw new ConvexError("Adicione pelo menos um item no checklist.")
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateId = options.generateId ?? (() => crypto.randomUUID())
|
const generateId = options.generateId ?? (() => crypto.randomUUID())
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const items: Array<{ id: string; text: string; required?: boolean }> = []
|
const items: NormalizedTemplateItem[] = []
|
||||||
|
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
const id = String(entry.id ?? "").trim() || generateId()
|
const id = String(entry.id ?? "").trim() || generateId()
|
||||||
|
|
@ -41,8 +61,25 @@ function normalizeTemplateItems(
|
||||||
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
|
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
|
const required = typeof entry.required === "boolean" ? entry.required : true
|
||||||
items.push({ id, text, required })
|
items.push({
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
description,
|
||||||
|
type: itemType,
|
||||||
|
options: itemOptions,
|
||||||
|
required,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
@ -57,6 +94,9 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co
|
||||||
items: (template.items ?? []).map((item) => ({
|
items: (template.items ?? []).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
text: item.text,
|
text: item.text,
|
||||||
|
description: item.description,
|
||||||
|
type: item.type ?? "checkbox",
|
||||||
|
options: item.options,
|
||||||
required: typeof item.required === "boolean" ? item.required : true,
|
required: typeof item.required === "boolean" ? item.required : true,
|
||||||
})),
|
})),
|
||||||
isArchived: Boolean(template.isArchived),
|
isArchived: Boolean(template.isArchived),
|
||||||
|
|
@ -164,6 +204,9 @@ export const create = mutation({
|
||||||
v.object({
|
v.object({
|
||||||
id: v.optional(v.string()),
|
id: v.optional(v.string()),
|
||||||
text: 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()),
|
required: v.optional(v.boolean()),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -216,6 +259,9 @@ export const update = mutation({
|
||||||
v.object({
|
v.object({
|
||||||
id: v.optional(v.string()),
|
id: v.optional(v.string()),
|
||||||
text: 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()),
|
required: v.optional(v.boolean()),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -279,3 +325,52 @@ export const remove = mutation({
|
||||||
return { ok: true }
|
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 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
273
convex/companySlas.ts
Normal file
273
convex/companySlas.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { mutation, query } from "./_generated/server"
|
||||||
|
import { ConvexError, v } from "convex/values"
|
||||||
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
|
import { requireAdmin } from "./rbac"
|
||||||
|
|
||||||
|
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
|
||||||
|
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
|
||||||
|
const VALID_TIME_MODES = ["business", "calendar"] as const
|
||||||
|
|
||||||
|
type CompanySlaRuleInput = {
|
||||||
|
priority: string
|
||||||
|
categoryId?: string | null
|
||||||
|
responseTargetMinutes?: number | null
|
||||||
|
responseMode?: string | null
|
||||||
|
solutionTargetMinutes?: number | null
|
||||||
|
solutionMode?: string | null
|
||||||
|
alertThreshold?: number | null
|
||||||
|
pauseStatuses?: string[] | null
|
||||||
|
calendarType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleInput = v.object({
|
||||||
|
priority: v.string(),
|
||||||
|
categoryId: v.optional(v.union(v.id("ticketCategories"), v.null())),
|
||||||
|
responseTargetMinutes: v.optional(v.number()),
|
||||||
|
responseMode: v.optional(v.string()),
|
||||||
|
solutionTargetMinutes: v.optional(v.number()),
|
||||||
|
solutionMode: v.optional(v.string()),
|
||||||
|
alertThreshold: v.optional(v.number()),
|
||||||
|
pauseStatuses: v.optional(v.array(v.string())),
|
||||||
|
calendarType: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizePriority(value: string) {
|
||||||
|
const upper = value.trim().toUpperCase()
|
||||||
|
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTime(value?: number | null) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
|
||||||
|
return Math.round(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(value?: string | null) {
|
||||||
|
if (!value) return "calendar"
|
||||||
|
const normalized = value.toLowerCase()
|
||||||
|
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThreshold(value?: number | null) {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
const clamped = Math.min(Math.max(value, 0.1), 0.95)
|
||||||
|
return Math.round(clamped * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePauseStatuses(value?: string[] | null) {
|
||||||
|
if (!Array.isArray(value)) return ["PAUSED"]
|
||||||
|
const normalized = new Set<string>()
|
||||||
|
for (const status of value) {
|
||||||
|
if (typeof status !== "string") continue
|
||||||
|
const upper = status.trim().toUpperCase()
|
||||||
|
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
|
||||||
|
normalized.add(upper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalized.size === 0) {
|
||||||
|
normalized.add("PAUSED")
|
||||||
|
}
|
||||||
|
return Array.from(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista todas as empresas que possuem SLA customizado
|
||||||
|
export const listCompaniesWithCustomSla = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
|
await requireAdmin(ctx, viewerId, tenantId)
|
||||||
|
|
||||||
|
// Busca todas as configurações de SLA por empresa
|
||||||
|
const allSettings = await ctx.db
|
||||||
|
.query("companySlaSettings")
|
||||||
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.take(1000)
|
||||||
|
|
||||||
|
// Agrupa por companyId para evitar duplicatas
|
||||||
|
const companyIds = [...new Set(allSettings.map((s) => s.companyId))]
|
||||||
|
|
||||||
|
// Busca dados das empresas
|
||||||
|
const companies = await Promise.all(
|
||||||
|
companyIds.map(async (companyId) => {
|
||||||
|
const company = await ctx.db.get(companyId)
|
||||||
|
if (!company) return null
|
||||||
|
const rulesCount = allSettings.filter((s) => s.companyId === companyId).length
|
||||||
|
return {
|
||||||
|
companyId,
|
||||||
|
companyName: company.name,
|
||||||
|
companySlug: company.slug,
|
||||||
|
rulesCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return companies.filter(Boolean)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Busca as regras de SLA de uma empresa específica
|
||||||
|
export const get = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
companyId: v.id("companies"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
||||||
|
await requireAdmin(ctx, viewerId, tenantId)
|
||||||
|
|
||||||
|
const company = await ctx.db.get(companyId)
|
||||||
|
if (!company || company.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Empresa não encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await ctx.db
|
||||||
|
.query("companySlaSettings")
|
||||||
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||||
|
.take(100)
|
||||||
|
|
||||||
|
// Busca nomes das categorias referenciadas
|
||||||
|
const categoryIds = [...new Set(records.filter((r) => r.categoryId).map((r) => r.categoryId!))]
|
||||||
|
const categories = await Promise.all(categoryIds.map((id) => ctx.db.get(id)))
|
||||||
|
const categoryNames = new Map(
|
||||||
|
categories.filter(Boolean).map((c) => [c!._id, c!.name])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyId,
|
||||||
|
companyName: company.name,
|
||||||
|
rules: records.map((record) => ({
|
||||||
|
priority: record.priority,
|
||||||
|
categoryId: record.categoryId ?? null,
|
||||||
|
categoryName: record.categoryId ? categoryNames.get(record.categoryId) ?? null : null,
|
||||||
|
responseTargetMinutes: record.responseTargetMinutes ?? null,
|
||||||
|
responseMode: record.responseMode ?? "calendar",
|
||||||
|
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
|
||||||
|
solutionMode: record.solutionMode ?? "calendar",
|
||||||
|
alertThreshold: record.alertThreshold ?? 0.8,
|
||||||
|
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Salva as regras de SLA de uma empresa
|
||||||
|
export const save = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
companyId: v.id("companies"),
|
||||||
|
rules: v.array(ruleInput),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, actorId, companyId, rules }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
|
|
||||||
|
const company = await ctx.db.get(companyId)
|
||||||
|
if (!company || company.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Empresa não encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida categorias referenciadas
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.categoryId) {
|
||||||
|
const category = await ctx.db.get(rule.categoryId)
|
||||||
|
if (!category || category.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError(`Categoria inválida: ${rule.categoryId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeRules(rules)
|
||||||
|
|
||||||
|
// Remove regras existentes da empresa
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("companySlaSettings")
|
||||||
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||||
|
.take(100)
|
||||||
|
|
||||||
|
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
|
||||||
|
|
||||||
|
// Insere novas regras
|
||||||
|
const now = Date.now()
|
||||||
|
for (const rule of sanitized) {
|
||||||
|
await ctx.db.insert("companySlaSettings", {
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
categoryId: rule.categoryId ? (rule.categoryId as Id<"ticketCategories">) : undefined,
|
||||||
|
priority: rule.priority,
|
||||||
|
responseTargetMinutes: rule.responseTargetMinutes,
|
||||||
|
responseMode: rule.responseMode,
|
||||||
|
solutionTargetMinutes: rule.solutionTargetMinutes,
|
||||||
|
solutionMode: rule.solutionMode,
|
||||||
|
alertThreshold: rule.alertThreshold,
|
||||||
|
pauseStatuses: rule.pauseStatuses,
|
||||||
|
calendarType: rule.calendarType ?? undefined,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
actorId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove todas as regras de SLA de uma empresa
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
companyId: v.id("companies"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, actorId, companyId }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
|
|
||||||
|
const company = await ctx.db.get(companyId)
|
||||||
|
if (!company || company.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Empresa não encontrada")
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("companySlaSettings")
|
||||||
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||||
|
.take(100)
|
||||||
|
|
||||||
|
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function sanitizeRules(rules: CompanySlaRuleInput[]) {
|
||||||
|
// Chave única: categoryId + priority
|
||||||
|
const normalized: Map<string, ReturnType<typeof buildRule>> = new Map()
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const built = buildRule(rule)
|
||||||
|
const key = `${built.categoryId ?? "ALL"}-${built.priority}`
|
||||||
|
normalized.set(key, built)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(normalized.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRule(rule: CompanySlaRuleInput) {
|
||||||
|
const priority = normalizePriority(rule.priority)
|
||||||
|
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
|
||||||
|
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
priority,
|
||||||
|
categoryId: rule.categoryId ?? null,
|
||||||
|
responseTargetMinutes,
|
||||||
|
responseMode: normalizeMode(rule.responseMode),
|
||||||
|
solutionTargetMinutes,
|
||||||
|
solutionMode: normalizeMode(rule.solutionMode),
|
||||||
|
alertThreshold: normalizeThreshold(rule.alertThreshold),
|
||||||
|
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
|
||||||
|
calendarType: rule.calendarType ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -168,7 +168,40 @@ export const startSession = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { sessionId, isNew: true }
|
// Iniciar timer automaticamente se nao houver sessao de trabalho ativa
|
||||||
|
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
|
||||||
|
let workSessionId: Id<"ticketWorkSessions"> | null = null
|
||||||
|
if (!ticket.activeSessionId && ticket.assigneeId) {
|
||||||
|
workSessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||||
|
ticketId,
|
||||||
|
agentId: ticket.assigneeId,
|
||||||
|
workType: "EXTERNAL",
|
||||||
|
startedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
working: true,
|
||||||
|
activeSessionId: workSessionId,
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "WORK_STARTED",
|
||||||
|
payload: {
|
||||||
|
actorId,
|
||||||
|
actorName: agent.name,
|
||||||
|
actorAvatar: agent.avatarUrl,
|
||||||
|
sessionId: workSessionId,
|
||||||
|
workType: "EXTERNAL",
|
||||||
|
source: "live_chat_auto",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,7 +258,60 @@ export const endSession = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true }
|
// Pausar timer automaticamente se houver sessao de trabalho ativa
|
||||||
|
let workSessionPaused = false
|
||||||
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
|
if (ticket?.activeSessionId) {
|
||||||
|
const workSession = await ctx.db.get(ticket.activeSessionId)
|
||||||
|
if (workSession && !workSession.stoppedAt) {
|
||||||
|
const workDurationMs = now - workSession.startedAt
|
||||||
|
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
|
||||||
|
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
|
||||||
|
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
|
||||||
|
|
||||||
|
// Encerrar sessao de trabalho
|
||||||
|
await ctx.db.patch(ticket.activeSessionId, {
|
||||||
|
stoppedAt: now,
|
||||||
|
durationMs: workDurationMs,
|
||||||
|
pauseReason: "END_LIVE_CHAT",
|
||||||
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Atualizar ticket
|
||||||
|
await ctx.db.patch(session.ticketId, {
|
||||||
|
working: false,
|
||||||
|
activeSessionId: undefined,
|
||||||
|
status: "PAUSED",
|
||||||
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
|
||||||
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||||
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Registrar evento de pausa
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: session.ticketId,
|
||||||
|
type: "WORK_PAUSED",
|
||||||
|
payload: {
|
||||||
|
actorId,
|
||||||
|
actorName: actor.name,
|
||||||
|
actorAvatar: actor.avatarUrl,
|
||||||
|
sessionId: workSession._id,
|
||||||
|
sessionDurationMs: workDurationMs,
|
||||||
|
workType: sessionType,
|
||||||
|
pauseReason: "END_LIVE_CHAT",
|
||||||
|
pauseReasonLabel: "Chat ao vivo encerrado",
|
||||||
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||||
|
source: "live_chat_auto",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
workSessionPaused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, workSessionPaused }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -417,8 +503,14 @@ export const listMachineSessions = query({
|
||||||
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
|
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
|
||||||
.take(50)
|
.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(
|
const result = await Promise.all(
|
||||||
sessions.map(async (session) => {
|
validSessions.map(async (session) => {
|
||||||
const ticket = await ctx.db.get(session.ticketId)
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
return {
|
return {
|
||||||
sessionId: session._id,
|
sessionId: session._id,
|
||||||
|
|
@ -520,13 +612,18 @@ export const checkMachineUpdates = query({
|
||||||
const { machine } = await validateMachineToken(ctx, args.machineToken)
|
const { machine } = await validateMachineToken(ctx, args.machineToken)
|
||||||
|
|
||||||
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
|
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
|
||||||
const sessions = await ctx.db
|
const rawSessions = await ctx.db
|
||||||
.query("liveChatSessions")
|
.query("liveChatSessions")
|
||||||
.withIndex("by_machine_status", (q) =>
|
.withIndex("by_machine_status", (q) =>
|
||||||
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
||||||
)
|
)
|
||||||
.take(50)
|
.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) {
|
if (sessions.length === 0) {
|
||||||
return {
|
return {
|
||||||
hasActiveSessions: false,
|
hasActiveSessions: false,
|
||||||
|
|
@ -763,27 +860,40 @@ export const getTicketChatHistory = query({
|
||||||
// Timeout de maquina offline: 5 minutos sem heartbeat
|
// Timeout de maquina offline: 5 minutos sem heartbeat
|
||||||
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
|
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron)
|
// Timeout de inatividade do chat: 12 horas sem atividade
|
||||||
// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat
|
// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar
|
||||||
// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens
|
const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
|
||||||
|
// Critérios de encerramento:
|
||||||
|
// 1. Máquina offline (5 min sem heartbeat)
|
||||||
|
// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online
|
||||||
|
// 3. Ticket órfão (sem máquina vinculada)
|
||||||
export const autoEndInactiveSessions = mutation({
|
export const autoEndInactiveSessions = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)")
|
console.log("cron: autoEndInactiveSessions iniciado")
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
|
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)
|
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
|
||||||
const maxSessionsPerRun = 50
|
const maxSessionsPerRun = 50
|
||||||
|
|
||||||
// Buscar todas as sessões ativas
|
// Buscar todas as sessões ativas
|
||||||
const activeSessions = await ctx.db
|
const rawActiveSessions = await ctx.db
|
||||||
.query("liveChatSessions")
|
.query("liveChatSessions")
|
||||||
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
|
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
|
||||||
.take(maxSessionsPerRun)
|
.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 endedCount = 0
|
||||||
let checkedCount = 0
|
let checkedCount = 0
|
||||||
|
const reasons: Record<string, number> = {}
|
||||||
|
|
||||||
for (const session of activeSessions) {
|
for (const session of activeSessions) {
|
||||||
checkedCount++
|
checkedCount++
|
||||||
|
|
@ -812,6 +922,36 @@ export const autoEndInactiveSessions = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
endedCount++
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -819,7 +959,7 @@ export const autoEndInactiveSessions = mutation({
|
||||||
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
|
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
|
||||||
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
|
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
|
||||||
|
|
||||||
// Se máquina está online, manter sessão ativa
|
// Se máquina está online e chat está ativo, manter sessão
|
||||||
if (machineIsOnline) {
|
if (machineIsOnline) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -849,10 +989,40 @@ export const autoEndInactiveSessions = mutation({
|
||||||
})
|
})
|
||||||
|
|
||||||
endedCount++
|
endedCount++
|
||||||
|
reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`)
|
const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ")
|
||||||
return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun }
|
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 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -867,6 +1037,13 @@ const ALLOWED_MIME_TYPES = [
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
|
// Audio
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/x-m4a",
|
||||||
// Documentos
|
// Documentos
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"text/plain",
|
"text/plain",
|
||||||
|
|
@ -878,6 +1055,7 @@ const ALLOWED_MIME_TYPES = [
|
||||||
|
|
||||||
const ALLOWED_EXTENSIONS = [
|
const ALLOWED_EXTENSIONS = [
|
||||||
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||||
|
".webm", ".ogg", ".wav", ".mp3", ".m4a",
|
||||||
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -931,7 +1109,8 @@ export const generateMachineUploadUrl = action({
|
||||||
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
|
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_MIME_TYPES.includes(args.fileType)) {
|
const normalizedType = args.fileType.split(";")[0].trim().toLowerCase()
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(normalizedType)) {
|
||||||
throw new ConvexError("Tipo MIME não permitido")
|
throw new ConvexError("Tipo MIME não permitido")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
276
convex/machineSoftware.ts
Normal file
276
convex/machineSoftware.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { mutation, query, internalMutation } from "./_generated/server"
|
||||||
|
import { v } from "convex/values"
|
||||||
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
|
// Tipo para software recebido do agente
|
||||||
|
type SoftwareInput = {
|
||||||
|
name: string
|
||||||
|
version?: string
|
||||||
|
publisher?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert de softwares de uma maquina (chamado pelo heartbeat)
|
||||||
|
export const syncFromHeartbeat = internalMutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
software: v.array(
|
||||||
|
v.object({
|
||||||
|
name: v.string(),
|
||||||
|
version: v.optional(v.string()),
|
||||||
|
publisher: v.optional(v.string()),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, machineId, software }) => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Busca softwares existentes da maquina
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s]))
|
||||||
|
|
||||||
|
// Processa cada software recebido
|
||||||
|
const seenKeys = new Set<string>()
|
||||||
|
for (const item of software) {
|
||||||
|
if (!item.name || item.name.trim().length === 0) continue
|
||||||
|
|
||||||
|
const nameLower = item.name.toLowerCase().trim()
|
||||||
|
const key = `${nameLower}|${item.version ?? ""}`
|
||||||
|
seenKeys.add(key)
|
||||||
|
|
||||||
|
const existingDoc = existingMap.get(key)
|
||||||
|
if (existingDoc) {
|
||||||
|
// Atualiza lastSeenAt se ja existe
|
||||||
|
await ctx.db.patch(existingDoc._id, {
|
||||||
|
lastSeenAt: now,
|
||||||
|
publisher: item.publisher || existingDoc.publisher,
|
||||||
|
source: item.source || existingDoc.source,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Cria novo registro
|
||||||
|
await ctx.db.insert("machineSoftware", {
|
||||||
|
tenantId,
|
||||||
|
machineId,
|
||||||
|
name: item.name.trim(),
|
||||||
|
nameLower,
|
||||||
|
version: item.version?.trim() || undefined,
|
||||||
|
publisher: item.publisher?.trim() || undefined,
|
||||||
|
source: item.source?.trim() || undefined,
|
||||||
|
detectedAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove softwares que nao foram vistos (desinstalados)
|
||||||
|
// So remove se o software nao foi visto nas ultimas 24 horas
|
||||||
|
const staleThreshold = now - 24 * 60 * 60 * 1000
|
||||||
|
for (const doc of existing) {
|
||||||
|
const key = `${doc.nameLower}|${doc.version ?? ""}`
|
||||||
|
if (!seenKeys.has(key) && doc.lastSeenAt < staleThreshold) {
|
||||||
|
await ctx.db.delete(doc._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed: software.length }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lista softwares de uma maquina com paginacao e filtros
|
||||||
|
export const listByMachine = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
search: v.optional(v.string()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { machineId, search, limit = 50, cursor }) => {
|
||||||
|
const pageLimit = Math.min(limit, 100)
|
||||||
|
|
||||||
|
let query = ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||||
|
|
||||||
|
// Coleta todos e filtra em memoria (Convex nao suporta LIKE)
|
||||||
|
const all = await query.collect()
|
||||||
|
|
||||||
|
// Filtra por search se fornecido
|
||||||
|
let filtered = all
|
||||||
|
if (search && search.trim().length > 0) {
|
||||||
|
const searchLower = search.toLowerCase().trim()
|
||||||
|
filtered = all.filter(
|
||||||
|
(s) =>
|
||||||
|
s.nameLower.includes(searchLower) ||
|
||||||
|
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
|
||||||
|
(s.version && s.version.toLowerCase().includes(searchLower))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordena por nome
|
||||||
|
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
|
||||||
|
|
||||||
|
// Paginacao manual
|
||||||
|
let startIndex = 0
|
||||||
|
if (cursor) {
|
||||||
|
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
|
||||||
|
if (cursorIndex >= 0) {
|
||||||
|
startIndex = cursorIndex + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filtered.slice(startIndex, startIndex + pageLimit)
|
||||||
|
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: page.map((s) => ({
|
||||||
|
id: s._id,
|
||||||
|
name: s.name,
|
||||||
|
version: s.version ?? null,
|
||||||
|
publisher: s.publisher ?? null,
|
||||||
|
source: s.source ?? null,
|
||||||
|
detectedAt: s.detectedAt,
|
||||||
|
lastSeenAt: s.lastSeenAt,
|
||||||
|
})),
|
||||||
|
total: filtered.length,
|
||||||
|
nextCursor,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lista softwares de todas as maquinas de um tenant (para admin)
|
||||||
|
export const listByTenant = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
search: v.optional(v.string()),
|
||||||
|
machineId: v.optional(v.id("machines")),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, search, machineId, limit = 50, cursor }) => {
|
||||||
|
const pageLimit = Math.min(limit, 100)
|
||||||
|
|
||||||
|
// Busca por tenant ou por maquina especifica
|
||||||
|
let all: Array<{
|
||||||
|
_id: Id<"machineSoftware">
|
||||||
|
tenantId: string
|
||||||
|
machineId: Id<"machines">
|
||||||
|
name: string
|
||||||
|
nameLower: string
|
||||||
|
version?: string
|
||||||
|
publisher?: string
|
||||||
|
source?: string
|
||||||
|
detectedAt: number
|
||||||
|
lastSeenAt: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
if (machineId) {
|
||||||
|
all = await ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machineId))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
// Busca por tenant - pode ser grande, limita
|
||||||
|
all = await ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.take(5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtra por search
|
||||||
|
let filtered = all
|
||||||
|
if (search && search.trim().length > 0) {
|
||||||
|
const searchLower = search.toLowerCase().trim()
|
||||||
|
filtered = all.filter(
|
||||||
|
(s) =>
|
||||||
|
s.nameLower.includes(searchLower) ||
|
||||||
|
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
|
||||||
|
(s.version && s.version.toLowerCase().includes(searchLower))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordena por nome
|
||||||
|
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
|
||||||
|
|
||||||
|
// Paginacao
|
||||||
|
let startIndex = 0
|
||||||
|
if (cursor) {
|
||||||
|
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
|
||||||
|
if (cursorIndex >= 0) {
|
||||||
|
startIndex = cursorIndex + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filtered.slice(startIndex, startIndex + pageLimit)
|
||||||
|
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
|
||||||
|
|
||||||
|
// Busca nomes das maquinas
|
||||||
|
const machineIds = [...new Set(page.map((s) => s.machineId))]
|
||||||
|
const machines = await Promise.all(machineIds.map((id) => ctx.db.get(id)))
|
||||||
|
const machineNames = new Map(
|
||||||
|
machines.filter(Boolean).map((m) => [m!._id, m!.displayName || m!.hostname])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: page.map((s) => ({
|
||||||
|
id: s._id,
|
||||||
|
machineId: s.machineId,
|
||||||
|
machineName: machineNames.get(s.machineId) ?? "Desconhecido",
|
||||||
|
name: s.name,
|
||||||
|
version: s.version ?? null,
|
||||||
|
publisher: s.publisher ?? null,
|
||||||
|
source: s.source ?? null,
|
||||||
|
detectedAt: s.detectedAt,
|
||||||
|
lastSeenAt: s.lastSeenAt,
|
||||||
|
})),
|
||||||
|
total: filtered.length,
|
||||||
|
nextCursor,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Conta softwares de uma maquina
|
||||||
|
export const countByMachine = query({
|
||||||
|
args: {
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { machineId }) => {
|
||||||
|
const software = await ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
return { count: software.length }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Conta softwares unicos por tenant (para relatorios)
|
||||||
|
export const stats = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId }) => {
|
||||||
|
const software = await ctx.db
|
||||||
|
.query("machineSoftware")
|
||||||
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.take(10000)
|
||||||
|
|
||||||
|
const uniqueNames = new Set(software.map((s) => s.nameLower))
|
||||||
|
const machineIds = new Set(software.map((s) => s.machineId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInstances: software.length,
|
||||||
|
uniqueSoftware: uniqueNames.size,
|
||||||
|
machinesWithSoftware: machineIds.size,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ci: trigger convex functions deploy (no-op)
|
// ci: trigger convex functions deploy (no-op)
|
||||||
import { mutation, query } from "./_generated/server"
|
import { mutation, query } from "./_generated/server"
|
||||||
import { api } from "./_generated/api"
|
import { internal, api } from "./_generated/api"
|
||||||
import { paginationOptsValidator } from "convex/server"
|
import { paginationOptsValidator } from "convex/server"
|
||||||
import { ConvexError, v, Infer } from "convex/values"
|
import { ConvexError, v, Infer } from "convex/values"
|
||||||
import { sha256 } from "@noble/hashes/sha2.js"
|
import { sha256 } from "@noble/hashes/sha2.js"
|
||||||
|
|
@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
|
||||||
return hb?.lastHeartbeatAt ?? fallback ?? null
|
return hb?.lastHeartbeatAt ?? fallback ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campos do inventory que sao muito grandes e nao devem ser persistidos
|
// Campo software é muito grande e é tratado separadamente via machineSoftware
|
||||||
// para evitar OOM no Convex (documentos de ~100KB cada)
|
|
||||||
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
|
// Extrai campos importantes do extended antes de bloqueá-lo
|
||||||
|
function extractFromExtended(extended: unknown): JsonRecord {
|
||||||
|
const result: JsonRecord = {}
|
||||||
|
const sanitizedExtended = sanitizeRecord(extended)
|
||||||
|
if (!sanitizedExtended) return result
|
||||||
|
|
||||||
|
// Extrair dados do Windows
|
||||||
|
const windows = sanitizeRecord(sanitizedExtended["windows"])
|
||||||
|
if (windows) {
|
||||||
|
const windowsFields: JsonRecord = {}
|
||||||
|
// bootInfo - informacoes de reinicio
|
||||||
|
if (windows["bootInfo"]) {
|
||||||
|
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
|
||||||
|
}
|
||||||
|
// osInfo - informacoes do sistema operacional
|
||||||
|
if (windows["osInfo"]) {
|
||||||
|
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
|
||||||
|
}
|
||||||
|
// cpu, baseboard, bios, memoryModules, videoControllers, disks
|
||||||
|
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
|
||||||
|
if (windows[key]) {
|
||||||
|
windowsFields[key] = windows[key] as JsonValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(windowsFields).length > 0) {
|
||||||
|
result["windows"] = windowsFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair dados do Linux
|
||||||
|
const linux = sanitizeRecord(sanitizedExtended["linux"])
|
||||||
|
if (linux) {
|
||||||
|
const linuxFields: JsonRecord = {}
|
||||||
|
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
|
||||||
|
if (linux[key]) {
|
||||||
|
linuxFields[key] = linux[key] as JsonValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(linuxFields).length > 0) {
|
||||||
|
result["linux"] = linuxFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair dados do macOS
|
||||||
|
const macos = sanitizeRecord(sanitizedExtended["macos"])
|
||||||
|
if (macos) {
|
||||||
|
result["macos"] = macos as JsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
|
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
|
||||||
const sanitizedPatch = sanitizeRecord(patch)
|
const sanitizedPatch = sanitizeRecord(patch)
|
||||||
|
|
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
|
||||||
return current ? { ...current } : {}
|
return current ? { ...current } : {}
|
||||||
}
|
}
|
||||||
const base: JsonRecord = current ? { ...current } : {}
|
const base: JsonRecord = current ? { ...current } : {}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
||||||
// Filtrar campos volumosos que causam OOM
|
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
|
||||||
if (INVENTORY_BLOCKLIST.has(key)) continue
|
if (key === "software") continue
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue
|
||||||
if (isObject(value) && isObject(base[key])) {
|
if (isObject(value) && isObject(base[key])) {
|
||||||
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
|
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
|
||||||
|
|
@ -393,9 +444,20 @@ function ensureString(value: unknown): string | null {
|
||||||
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
|
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
|
||||||
const record = sanitizeRecord(value)
|
const record = sanitizeRecord(value)
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
for (const blocked of INVENTORY_BLOCKLIST) {
|
|
||||||
delete record[blocked]
|
// Extrair campos importantes do extended antes de deletá-lo
|
||||||
|
if (record["extended"]) {
|
||||||
|
const extractedExtended = extractFromExtended(record["extended"])
|
||||||
|
if (Object.keys(extractedExtended).length > 0) {
|
||||||
|
record["extended"] = extractedExtended
|
||||||
|
} else {
|
||||||
|
delete record["extended"]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletar apenas software (extended já foi processado acima)
|
||||||
|
delete record["software"]
|
||||||
|
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,10 +1018,13 @@ export const heartbeat = mutation({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedInventory = sanitizeInventoryPayload(args.inventory)
|
// Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata)
|
||||||
|
const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record<string, unknown> | undefined)
|
||||||
|
const sanitizedInventory = sanitizeInventoryPayload(rawInventory)
|
||||||
const currentInventory = ensureRecord(currentMetadata.inventory)
|
const currentInventory = ensureRecord(currentMetadata.inventory)
|
||||||
const incomingInventoryHash = hashJson(sanitizedInventory)
|
const incomingInventoryHash = hashJson(sanitizedInventory)
|
||||||
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
|
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
|
||||||
|
|
||||||
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
|
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
|
||||||
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
|
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
|
||||||
metadataPatch.inventoryHash = incomingInventoryHash
|
metadataPatch.inventoryHash = incomingInventoryHash
|
||||||
|
|
@ -1010,6 +1075,34 @@ export const heartbeat = mutation({
|
||||||
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
|
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Processar softwares instalados (armazenados em tabela separada)
|
||||||
|
// Os dados de software sao extraidos ANTES de sanitizar o inventory
|
||||||
|
// Usa rawInventory ja extraido anteriormente (linha ~1022)
|
||||||
|
if (rawInventory && typeof rawInventory === "object") {
|
||||||
|
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
|
||||||
|
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
|
||||||
|
const validSoftware = softwareArray
|
||||||
|
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
|
||||||
|
.map((item) => ({
|
||||||
|
name: typeof item.name === "string" ? item.name : "",
|
||||||
|
version: typeof item.version === "string" ? item.version : undefined,
|
||||||
|
publisher: typeof item.publisher === "string" || typeof item.source === "string"
|
||||||
|
? (item.publisher as string) || (item.source as string)
|
||||||
|
: undefined,
|
||||||
|
source: typeof item.source === "string" ? item.source : undefined,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name.length > 0)
|
||||||
|
|
||||||
|
if (validSoftware.length > 0) {
|
||||||
|
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
|
||||||
|
tenantId: machine.tenantId,
|
||||||
|
machineId: machine._id,
|
||||||
|
software: validSoftware,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db.patch(token._id, {
|
await ctx.db.patch(token._id, {
|
||||||
lastUsedAt: now,
|
lastUsedAt: now,
|
||||||
usageCount: (token.usageCount ?? 0) + 1,
|
usageCount: (token.usageCount ?? 0) + 1,
|
||||||
|
|
@ -2317,6 +2410,44 @@ export const resetAgent = mutation({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query para o desktop monitorar o estado da máquina em tempo real.
|
||||||
|
* O desktop faz subscribe nessa query e reage imediatamente quando:
|
||||||
|
* - isActive muda para false (desativação)
|
||||||
|
* - hasValidToken muda para false (reset/revogação de tokens)
|
||||||
|
*/
|
||||||
|
export const getMachineState = query({
|
||||||
|
args: {
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { machineId }) => {
|
||||||
|
const machine = await ctx.db.get(machineId)
|
||||||
|
if (!machine) {
|
||||||
|
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se existe algum token válido (não revogado e não expirado)
|
||||||
|
const now = Date.now()
|
||||||
|
const tokens = await ctx.db
|
||||||
|
.query("machineTokens")
|
||||||
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||||
|
.take(10)
|
||||||
|
|
||||||
|
const hasValidToken = tokens.some((token) => {
|
||||||
|
if (token.revoked) return false
|
||||||
|
if (token.expiresAt && token.expiresAt < now) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
isActive: machine.isActive ?? true,
|
||||||
|
hasValidToken,
|
||||||
|
status: machine.status ?? "unknown",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
type RemoteAccessEntry = {
|
type RemoteAccessEntry = {
|
||||||
id: string
|
id: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|
|
||||||
|
|
@ -1043,3 +1043,81 @@ export const backfillTicketSnapshots = mutation({
|
||||||
return { processed }
|
return { processed }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration para remover comentarios duplicados de troca de responsavel.
|
||||||
|
* Esses comentarios eram criados automaticamente ao trocar o responsavel,
|
||||||
|
* mas essa informacao ja aparece na linha do tempo (ticketEvents).
|
||||||
|
* O comentario segue o padrao: "<p><strong>Responsável atualizado:</strong>..."
|
||||||
|
*/
|
||||||
|
export const removeAssigneeChangeComments = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.optional(v.string()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
dryRun: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, limit, dryRun }) => {
|
||||||
|
const effectiveDryRun = Boolean(dryRun)
|
||||||
|
const effectiveLimit = limit && limit > 0 ? Math.min(limit, 500) : 500
|
||||||
|
|
||||||
|
// Busca comentarios internos que contenham o padrao de troca de responsavel
|
||||||
|
const comments = tenantId && tenantId.trim().length > 0
|
||||||
|
? await ctx.db.query("ticketComments").take(5000)
|
||||||
|
: await ctx.db.query("ticketComments").take(5000)
|
||||||
|
|
||||||
|
// Filtrar comentarios que sao de troca de responsavel
|
||||||
|
const assigneeChangePattern = "<p><strong>Responsável atualizado:</strong>"
|
||||||
|
const toDelete = comments.filter((comment) => {
|
||||||
|
if (comment.visibility !== "INTERNAL") return false
|
||||||
|
if (typeof comment.body !== "string") return false
|
||||||
|
return comment.body.includes(assigneeChangePattern)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtrar por tenant se especificado
|
||||||
|
let filtered = toDelete
|
||||||
|
if (tenantId && tenantId.trim().length > 0) {
|
||||||
|
const ticketIds = new Set<string>()
|
||||||
|
const tickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.take(10000)
|
||||||
|
for (const t of tickets) {
|
||||||
|
ticketIds.add(t._id)
|
||||||
|
}
|
||||||
|
filtered = toDelete.filter((c) => ticketIds.has(c.ticketId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedComments = filtered.slice(0, effectiveLimit)
|
||||||
|
let deleted = 0
|
||||||
|
let eventsDeleted = 0
|
||||||
|
|
||||||
|
for (const comment of limitedComments) {
|
||||||
|
if (!effectiveDryRun) {
|
||||||
|
// Deletar o evento COMMENT_ADDED correspondente
|
||||||
|
const events = await ctx.db
|
||||||
|
.query("ticketEvents")
|
||||||
|
.withIndex("by_ticket", (q) => q.eq("ticketId", comment.ticketId))
|
||||||
|
.take(500)
|
||||||
|
const matchingEvent = events.find(
|
||||||
|
(event) =>
|
||||||
|
event.type === "COMMENT_ADDED" &&
|
||||||
|
Math.abs(event.createdAt - comment.createdAt) < 1000, // mesmo timestamp (tolerancia de 1s)
|
||||||
|
)
|
||||||
|
if (matchingEvent) {
|
||||||
|
await ctx.db.delete(matchingEvent._id)
|
||||||
|
eventsDeleted += 1
|
||||||
|
}
|
||||||
|
await ctx.db.delete(comment._id)
|
||||||
|
}
|
||||||
|
deleted += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dryRun: effectiveDryRun,
|
||||||
|
totalFound: filtered.length,
|
||||||
|
deleted,
|
||||||
|
eventsDeleted,
|
||||||
|
remaining: filtered.length - deleted,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -154,10 +154,17 @@ export const summary = query({
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const ticket of tickets) {
|
for (const ticket of tickets) {
|
||||||
const status = normalizeStatus(ticket.status);
|
const status = normalizeStatus(ticket.status);
|
||||||
|
const isWorking = ticket.working === true;
|
||||||
if (status === "PENDING") {
|
if (status === "PENDING") {
|
||||||
pending += 1;
|
pending += 1;
|
||||||
} else if (status === "AWAITING_ATTENDANCE") {
|
} else if (status === "AWAITING_ATTENDANCE") {
|
||||||
|
// "Em andamento" conta apenas tickets com play ativo
|
||||||
|
if (isWorking) {
|
||||||
inProgress += 1;
|
inProgress += 1;
|
||||||
|
} else {
|
||||||
|
// Tickets em atendimento sem play ativo contam como "Em aberto"
|
||||||
|
pending += 1;
|
||||||
|
}
|
||||||
} else if (status === "PAUSED") {
|
} else if (status === "PAUSED") {
|
||||||
paused += 1;
|
paused += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,31 @@ import { render } from "@react-email/render"
|
||||||
|
|
||||||
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
||||||
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
|
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
|
||||||
|
import InviteEmail, { type InviteEmailProps } from "../emails/invite-email"
|
||||||
|
import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email"
|
||||||
|
import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email"
|
||||||
|
import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email"
|
||||||
|
import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email"
|
||||||
|
import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email"
|
||||||
|
import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email"
|
||||||
|
import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email"
|
||||||
|
import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email"
|
||||||
|
import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email"
|
||||||
|
|
||||||
export type { AutomationEmailProps, SimpleNotificationEmailProps }
|
export type {
|
||||||
|
AutomationEmailProps,
|
||||||
|
SimpleNotificationEmailProps,
|
||||||
|
InviteEmailProps,
|
||||||
|
PasswordResetEmailProps,
|
||||||
|
NewLoginEmailProps,
|
||||||
|
SlaWarningEmailProps,
|
||||||
|
SlaBreachedEmailProps,
|
||||||
|
TicketCreatedEmailProps,
|
||||||
|
TicketResolvedEmailProps,
|
||||||
|
TicketAssignedEmailProps,
|
||||||
|
TicketStatusEmailProps,
|
||||||
|
TicketCommentEmailProps,
|
||||||
|
}
|
||||||
|
|
||||||
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||||
return render(<AutomationEmail {...props} />, { pretty: false })
|
return render(<AutomationEmail {...props} />, { pretty: false })
|
||||||
|
|
@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||||
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
||||||
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renderInviteEmailHtml(props: InviteEmailProps) {
|
||||||
|
return render(<InviteEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) {
|
||||||
|
return render(<PasswordResetEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) {
|
||||||
|
return render(<NewLoginEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) {
|
||||||
|
return render(<SlaWarningEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) {
|
||||||
|
return render(<SlaBreachedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) {
|
||||||
|
return render(<TicketCreatedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) {
|
||||||
|
return render(<TicketResolvedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) {
|
||||||
|
return render(<TicketAssignedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) {
|
||||||
|
return render(<TicketStatusEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) {
|
||||||
|
return render(<TicketCommentEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,11 +161,8 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logDashboardProgress(processed: number, tenantId: string) {
|
function logDashboardProgress(_processed: number, _tenantId: string) {
|
||||||
const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024));
|
// Log de progresso removido para reduzir ruido no console
|
||||||
console.log(
|
|
||||||
`[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapToChronologicalSeries(map: Map<string, number>) {
|
function mapToChronologicalSeries(map: Map<string, number>) {
|
||||||
|
|
@ -2406,19 +2403,21 @@ export const companyOverview = query({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
viewerId: v.id("users"),
|
viewerId: v.id("users"),
|
||||||
companyId: v.id("companies"),
|
companyId: v.optional(v.id("companies")),
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
|
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) {
|
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||||
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = await ctx.db.get(companyId);
|
// 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) {
|
if (!company || company.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Empresa não encontrada");
|
throw new ConvexError("Empresa não encontrada");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedRange = (range ?? "30d").toLowerCase();
|
const normalizedRange = (range ?? "30d").toLowerCase();
|
||||||
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
|
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
|
||||||
|
|
@ -2426,19 +2425,34 @@ export const companyOverview = query({
|
||||||
const startMs = now - rangeDays * ONE_DAY_MS;
|
const startMs = now - rangeDays * ONE_DAY_MS;
|
||||||
|
|
||||||
// Limita consultas para evitar OOM em empresas muito grandes
|
// Limita consultas para evitar OOM em empresas muito grandes
|
||||||
const tickets = await ctx.db
|
const tickets = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.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);
|
.take(2000);
|
||||||
|
|
||||||
const machines = await ctx.db
|
const machines = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("machines")
|
.query("machines")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.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);
|
.take(1000);
|
||||||
|
|
||||||
const users = await ctx.db
|
const users = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("users")
|
.query("users")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.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);
|
.take(500);
|
||||||
|
|
||||||
const statusCounts = {} as Record<string, number>;
|
const statusCounts = {} as Record<string, number>;
|
||||||
|
|
@ -2534,11 +2548,13 @@ export const companyOverview = query({
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: {
|
company: company
|
||||||
|
? {
|
||||||
id: company._id,
|
id: company._id,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
isAvulso: company.isAvulso ?? false,
|
isAvulso: company.isAvulso ?? false,
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
rangeDays,
|
rangeDays,
|
||||||
generatedAt: now,
|
generatedAt: now,
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export default defineSchema({
|
||||||
contacts: v.optional(v.any()),
|
contacts: v.optional(v.any()),
|
||||||
locations: v.optional(v.any()),
|
locations: v.optional(v.any()),
|
||||||
sla: v.optional(v.any()),
|
sla: v.optional(v.any()),
|
||||||
|
reopenWindowDays: v.optional(v.number()),
|
||||||
tags: v.optional(v.array(v.string())),
|
tags: v.optional(v.array(v.string())),
|
||||||
customFields: v.optional(v.any()),
|
customFields: v.optional(v.any()),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
|
|
@ -199,7 +200,11 @@ export default defineSchema({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()), // minutes
|
timeToFirstResponse: v.optional(v.number()), // minutes
|
||||||
|
responseMode: v.optional(v.string()), // "business" | "calendar"
|
||||||
timeToResolution: v.optional(v.number()), // minutes
|
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"]),
|
}).index("by_tenant_name", ["tenantId", "name"]),
|
||||||
|
|
||||||
tickets: defineTable({
|
tickets: defineTable({
|
||||||
|
|
@ -314,10 +319,15 @@ export default defineSchema({
|
||||||
v.object({
|
v.object({
|
||||||
id: v.string(),
|
id: v.string(),
|
||||||
text: 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(),
|
done: v.boolean(),
|
||||||
required: v.optional(v.boolean()),
|
required: v.optional(v.boolean()),
|
||||||
templateId: v.optional(v.id("ticketChecklistTemplates")),
|
templateId: v.optional(v.id("ticketChecklistTemplates")),
|
||||||
templateItemId: v.optional(v.string()),
|
templateItemId: v.optional(v.string()),
|
||||||
|
templateDescription: v.optional(v.string()), // Descricao do template (copiada ao aplicar)
|
||||||
createdAt: v.optional(v.number()),
|
createdAt: v.optional(v.number()),
|
||||||
createdBy: v.optional(v.id("users")),
|
createdBy: v.optional(v.id("users")),
|
||||||
doneAt: v.optional(v.number()),
|
doneAt: v.optional(v.number()),
|
||||||
|
|
@ -478,6 +488,7 @@ export default defineSchema({
|
||||||
startedAt: v.number(),
|
startedAt: v.number(),
|
||||||
endedAt: v.optional(v.number()),
|
endedAt: v.optional(v.number()),
|
||||||
lastActivityAt: v.number(),
|
lastActivityAt: v.number(),
|
||||||
|
lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel)
|
||||||
unreadByMachine: v.optional(v.number()),
|
unreadByMachine: v.optional(v.number()),
|
||||||
unreadByAgent: v.optional(v.number()),
|
unreadByAgent: v.optional(v.number()),
|
||||||
})
|
})
|
||||||
|
|
@ -587,6 +598,29 @@ export default defineSchema({
|
||||||
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
|
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
|
||||||
.index("by_tenant_category", ["tenantId", "categoryId"]),
|
.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({
|
ticketFields: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
key: v.string(),
|
key: v.string(),
|
||||||
|
|
@ -658,6 +692,9 @@ export default defineSchema({
|
||||||
v.object({
|
v.object({
|
||||||
id: v.string(),
|
id: v.string(),
|
||||||
text: 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()),
|
required: v.optional(v.boolean()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
@ -788,6 +825,25 @@ export default defineSchema({
|
||||||
})
|
})
|
||||||
.index("by_machine", ["machineId"]),
|
.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({
|
machineTokens: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@ function normalizeName(value: string) {
|
||||||
return value.trim();
|
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;
|
type AnyCtx = QueryCtx | MutationCtx;
|
||||||
|
|
||||||
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
||||||
|
|
@ -35,7 +55,11 @@ export const list = query({
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
description: policy.description ?? "",
|
description: policy.description ?? "",
|
||||||
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
||||||
|
responseMode: policy.responseMode ?? "calendar",
|
||||||
timeToResolution: policy.timeToResolution ?? null,
|
timeToResolution: policy.timeToResolution ?? null,
|
||||||
|
solutionMode: policy.solutionMode ?? "calendar",
|
||||||
|
alertThreshold: policy.alertThreshold ?? 0.8,
|
||||||
|
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -47,9 +71,14 @@ export const create = mutation({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()),
|
timeToFirstResponse: v.optional(v.number()),
|
||||||
|
responseMode: v.optional(v.string()),
|
||||||
timeToResolution: v.optional(v.number()),
|
timeToResolution: v.optional(v.number()),
|
||||||
|
solutionMode: v.optional(v.string()),
|
||||||
|
alertThreshold: v.optional(v.number()),
|
||||||
|
pauseStatuses: v.optional(v.array(v.string())),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
handler: async (ctx, args) => {
|
||||||
|
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||||
await requireAdmin(ctx, actorId, tenantId);
|
await requireAdmin(ctx, actorId, tenantId);
|
||||||
const trimmed = normalizeName(name);
|
const trimmed = normalizeName(name);
|
||||||
if (trimmed.length < 2) {
|
if (trimmed.length < 2) {
|
||||||
|
|
@ -68,7 +97,11 @@ export const create = mutation({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description,
|
description,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode: normalizeMode(responseMode),
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode: normalizeMode(solutionMode),
|
||||||
|
alertThreshold: normalizeThreshold(alertThreshold),
|
||||||
|
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
@ -82,9 +115,14 @@ export const update = mutation({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()),
|
timeToFirstResponse: v.optional(v.number()),
|
||||||
|
responseMode: v.optional(v.string()),
|
||||||
timeToResolution: v.optional(v.number()),
|
timeToResolution: v.optional(v.number()),
|
||||||
|
solutionMode: v.optional(v.string()),
|
||||||
|
alertThreshold: v.optional(v.number()),
|
||||||
|
pauseStatuses: v.optional(v.array(v.string())),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
handler: async (ctx, args) => {
|
||||||
|
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||||
await requireAdmin(ctx, actorId, tenantId);
|
await requireAdmin(ctx, actorId, tenantId);
|
||||||
const policy = await ctx.db.get(policyId);
|
const policy = await ctx.db.get(policyId);
|
||||||
if (!policy || policy.tenantId !== tenantId) {
|
if (!policy || policy.tenantId !== tenantId) {
|
||||||
|
|
@ -106,7 +144,11 @@ export const update = mutation({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description,
|
description,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode: normalizeMode(responseMode),
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode: normalizeMode(solutionMode),
|
||||||
|
alertThreshold: normalizeThreshold(alertThreshold),
|
||||||
|
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,38 @@
|
||||||
import type { Id } from "./_generated/dataModel"
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
|
export type ChecklistItemType = "checkbox" | "question"
|
||||||
|
|
||||||
export type TicketChecklistItem = {
|
export type TicketChecklistItem = {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
|
description?: string
|
||||||
|
type?: ChecklistItemType
|
||||||
|
options?: string[] // Para tipo "question": ["Sim", "Nao", ...]
|
||||||
|
answer?: string // Resposta selecionada para tipo "question"
|
||||||
done: boolean
|
done: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
templateId?: Id<"ticketChecklistTemplates">
|
templateId?: Id<"ticketChecklistTemplates">
|
||||||
templateItemId?: string
|
templateItemId?: string
|
||||||
|
templateDescription?: string // Descricao do template (copiada ao aplicar)
|
||||||
createdAt?: number
|
createdAt?: number
|
||||||
createdBy?: Id<"users">
|
createdBy?: Id<"users">
|
||||||
doneAt?: number
|
doneAt?: number
|
||||||
doneBy?: Id<"users">
|
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 = {
|
export type TicketChecklistTemplateLike = {
|
||||||
_id: Id<"ticketChecklistTemplates">
|
_id: Id<"ticketChecklistTemplates">
|
||||||
items: Array<{ id: string; text: string; required?: boolean }>
|
description?: string
|
||||||
|
items: TicketChecklistTemplateItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeChecklistText(input: string) {
|
export function normalizeChecklistText(input: string) {
|
||||||
|
|
@ -53,13 +70,18 @@ export function applyChecklistTemplateToItems(
|
||||||
const key = `${String(template._id)}:${templateItemId}`
|
const key = `${String(template._id)}:${templateItemId}`
|
||||||
if (existingKeys.has(key)) continue
|
if (existingKeys.has(key)) continue
|
||||||
existingKeys.add(key)
|
existingKeys.add(key)
|
||||||
|
const itemType = tplItem.type ?? "checkbox"
|
||||||
next.push({
|
next.push({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
text,
|
text,
|
||||||
|
description: tplItem.description,
|
||||||
|
type: itemType as ChecklistItemType,
|
||||||
|
options: itemType === "question" ? tplItem.options : undefined,
|
||||||
done: false,
|
done: false,
|
||||||
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
|
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
|
||||||
templateId: template._id,
|
templateId: template._id,
|
||||||
templateItemId,
|
templateItemId,
|
||||||
|
templateDescription: template.description,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
createdBy: options.actorId,
|
createdBy: options.actorId,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,45 @@ import { v } from "convex/values"
|
||||||
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||||
import { buildBaseUrl } from "./url"
|
import { buildBaseUrl } from "./url"
|
||||||
|
|
||||||
|
// API do Next.js para verificar preferências
|
||||||
|
async function sendViaNextApi(params: {
|
||||||
|
type: string
|
||||||
|
to: { email: string; name?: string; userId?: string }
|
||||||
|
subject: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
tenantId?: string
|
||||||
|
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
|
||||||
|
return { success: false, reason: "no_token" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/notifications/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
console.error("[ticketNotifications] Erro na API:", error)
|
||||||
|
return { success: false, reason: "api_error" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ticketNotifications] Erro ao chamar API:", error)
|
||||||
|
return { success: false, reason: "fetch_error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function b64(input: string) {
|
function b64(input: string) {
|
||||||
return Buffer.from(input, "utf8").toString("base64")
|
return Buffer.from(input, "utf8").toString("base64")
|
||||||
}
|
}
|
||||||
|
|
@ -281,25 +320,109 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendPublicCommentEmail = action({
|
export const sendTicketCreatedEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
ticketId: v.string(),
|
ticketId: v.string(),
|
||||||
reference: v.number(),
|
reference: v.number(),
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
|
priority: v.string(),
|
||||||
|
tenantId: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
|
|
||||||
|
const priorityLabels: Record<string, string> = {
|
||||||
|
LOW: "Baixa",
|
||||||
|
MEDIUM: "Média",
|
||||||
|
HIGH: "Alta",
|
||||||
|
URGENT: "Urgente",
|
||||||
|
}
|
||||||
|
const priorityLabel = priorityLabels[priority] ?? priority
|
||||||
|
const mailSubject = `Novo chamado #${reference} aberto`
|
||||||
|
|
||||||
|
// Tenta usar a API do Next.js para verificar preferências
|
||||||
|
const apiResult = await sendViaNextApi({
|
||||||
|
type: "ticket_created",
|
||||||
|
to: { email: to, name: userName, userId },
|
||||||
|
subject: mailSubject,
|
||||||
|
data: {
|
||||||
|
reference,
|
||||||
|
subject,
|
||||||
|
status: "Pendente",
|
||||||
|
priority: priorityLabel,
|
||||||
|
viewUrl: url,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (apiResult.success || apiResult.skipped) {
|
||||||
|
return apiResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: envia diretamente se a API falhar
|
||||||
|
const smtp = buildSmtpConfig()
|
||||||
|
if (!smtp) {
|
||||||
|
console.warn("SMTP not configured; skipping ticket created email")
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
|
title: `Novo chamado #${reference} aberto`,
|
||||||
|
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
|
||||||
|
ctaLabel: "Ver chamado",
|
||||||
|
ctaUrl: url,
|
||||||
|
})
|
||||||
|
await sendSmtpMail(smtp, to, mailSubject, html)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const sendPublicCommentEmail = action({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
|
ticketId: v.string(),
|
||||||
|
reference: v.number(),
|
||||||
|
subject: v.string(),
|
||||||
|
tenantId: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
|
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||||
|
|
||||||
|
// Tenta usar a API do Next.js para verificar preferências
|
||||||
|
const apiResult = await sendViaNextApi({
|
||||||
|
type: "comment_public",
|
||||||
|
to: { email: to, name: userName, userId },
|
||||||
|
subject: mailSubject,
|
||||||
|
data: {
|
||||||
|
reference,
|
||||||
|
subject,
|
||||||
|
viewUrl: url,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (apiResult.success || apiResult.skipped) {
|
||||||
|
return apiResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: envia diretamente se a API falhar
|
||||||
const smtp = buildSmtpConfig()
|
const smtp = buildSmtpConfig()
|
||||||
if (!smtp) {
|
if (!smtp) {
|
||||||
console.warn("SMTP not configured; skipping ticket comment email")
|
console.warn("SMTP not configured; skipping ticket comment email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
|
||||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
|
||||||
const html = await renderSimpleNotificationEmailHtml({
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
title: `Nova atualização no seu chamado #${reference}`,
|
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",
|
ctaLabel: "Abrir e responder",
|
||||||
ctaUrl: url,
|
ctaUrl: url,
|
||||||
})
|
})
|
||||||
|
|
@ -311,22 +434,45 @@ export const sendPublicCommentEmail = action({
|
||||||
export const sendResolvedEmail = action({
|
export const sendResolvedEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.string(),
|
to: v.string(),
|
||||||
|
userId: v.optional(v.string()),
|
||||||
|
userName: v.optional(v.string()),
|
||||||
ticketId: v.string(),
|
ticketId: v.string(),
|
||||||
reference: v.number(),
|
reference: v.number(),
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
|
tenantId: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||||
|
const baseUrl = buildBaseUrl()
|
||||||
|
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||||
|
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||||
|
|
||||||
|
// Tenta usar a API do Next.js para verificar preferências
|
||||||
|
const apiResult = await sendViaNextApi({
|
||||||
|
type: "ticket_resolved",
|
||||||
|
to: { email: to, name: userName, userId },
|
||||||
|
subject: mailSubject,
|
||||||
|
data: {
|
||||||
|
reference,
|
||||||
|
subject,
|
||||||
|
viewUrl: url,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (apiResult.success || apiResult.skipped) {
|
||||||
|
return apiResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: envia diretamente se a API falhar
|
||||||
const smtp = buildSmtpConfig()
|
const smtp = buildSmtpConfig()
|
||||||
if (!smtp) {
|
if (!smtp) {
|
||||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||||
return { skipped: true }
|
return { skipped: true }
|
||||||
}
|
}
|
||||||
const baseUrl = buildBaseUrl()
|
|
||||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
|
||||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
|
||||||
const html = await renderSimpleNotificationEmailHtml({
|
const html = await renderSimpleNotificationEmailHtml({
|
||||||
title: `Chamado #${reference} encerrado`,
|
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",
|
ctaLabel: "Ver detalhes",
|
||||||
ctaUrl: url,
|
ctaUrl: url,
|
||||||
})
|
})
|
||||||
|
|
@ -339,9 +485,23 @@ export const sendAutomationEmail = action({
|
||||||
args: {
|
args: {
|
||||||
to: v.array(v.string()),
|
to: v.array(v.string()),
|
||||||
subject: v.string(),
|
subject: v.string(),
|
||||||
html: v.string(),
|
emailProps: v.object({
|
||||||
|
title: v.string(),
|
||||||
|
message: v.string(),
|
||||||
|
ticket: v.object({
|
||||||
|
reference: v.number(),
|
||||||
|
subject: v.string(),
|
||||||
|
status: v.optional(v.union(v.string(), v.null())),
|
||||||
|
priority: v.optional(v.union(v.string(), v.null())),
|
||||||
|
companyName: v.optional(v.union(v.string(), v.null())),
|
||||||
|
requesterName: v.optional(v.union(v.string(), v.null())),
|
||||||
|
assigneeName: v.optional(v.union(v.string(), v.null())),
|
||||||
|
}),
|
||||||
|
ctaLabel: v.string(),
|
||||||
|
ctaUrl: v.string(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
handler: async (_ctx, { to, subject, html }) => {
|
handler: async (_ctx, { to, subject, emailProps }) => {
|
||||||
const smtp = buildSmtpConfig()
|
const smtp = buildSmtpConfig()
|
||||||
if (!smtp) {
|
if (!smtp) {
|
||||||
console.warn("SMTP not configured; skipping automation email")
|
console.warn("SMTP not configured; skipping automation email")
|
||||||
|
|
@ -357,10 +517,45 @@ export const sendAutomationEmail = action({
|
||||||
return { skipped: true, reason: "no_recipients" }
|
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) {
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
await sendSmtpMail(smtp, recipient, subject, html)
|
await sendSmtpMail(smtp, recipient, subject, html)
|
||||||
|
results.push({ recipient, sent: true })
|
||||||
|
console.log(`[automation-email] Enviado para ${recipient}`)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
results.push({ recipient, sent: false, error: errorMessage })
|
||||||
|
console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, sent: recipients.length }
|
const sent = results.filter((r) => r.sent).length
|
||||||
|
const failed = results.filter((r) => !r.sent).length
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: sent > 0, sent, failed, results }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||||
NO_CONTACT: "Falta de contato",
|
NO_CONTACT: "Falta de contato",
|
||||||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||||
IN_PROCEDURE: "Em procedimento",
|
IN_PROCEDURE: "Em procedimento",
|
||||||
|
END_LIVE_CHAT: "Chat ao vivo encerrado",
|
||||||
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
|
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
|
||||||
};
|
};
|
||||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
@ -272,13 +273,60 @@ async function resolveTicketSlaSnapshot(
|
||||||
ctx: AnyCtx,
|
ctx: AnyCtx,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
category: Doc<"ticketCategories"> | null,
|
category: Doc<"ticketCategories"> | null,
|
||||||
priority: string
|
priority: string,
|
||||||
|
companyId?: Id<"companies"> | null
|
||||||
): Promise<TicketSlaSnapshot | null> {
|
): Promise<TicketSlaSnapshot | null> {
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedPriority = priority.trim().toUpperCase();
|
const normalizedPriority = priority.trim().toUpperCase();
|
||||||
const rule =
|
|
||||||
|
// 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado)
|
||||||
|
let rule: {
|
||||||
|
responseTargetMinutes?: number;
|
||||||
|
responseMode?: string;
|
||||||
|
solutionTargetMinutes?: number;
|
||||||
|
solutionMode?: string;
|
||||||
|
alertThreshold?: number;
|
||||||
|
pauseStatuses?: string[];
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
// Tenta: empresa + categoria + prioridade
|
||||||
|
rule = await ctx.db
|
||||||
|
.query("companySlaSettings")
|
||||||
|
.withIndex("by_tenant_company_category_priority", (q) =>
|
||||||
|
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// 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
|
(await ctx.db
|
||||||
.query("categorySlaSettings")
|
.query("categorySlaSettings")
|
||||||
.withIndex("by_tenant_category_priority", (q) =>
|
.withIndex("by_tenant_category_priority", (q) =>
|
||||||
|
|
@ -291,6 +339,8 @@ async function resolveTicketSlaSnapshot(
|
||||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||||
)
|
)
|
||||||
.first());
|
.first());
|
||||||
|
}
|
||||||
|
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -872,23 +922,6 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAssigneeChangeComment(
|
|
||||||
reason: string,
|
|
||||||
context: { previousName: string; nextName: string },
|
|
||||||
): string {
|
|
||||||
const normalized = reason.replace(/\r\n/g, "\n").trim();
|
|
||||||
const lines = normalized
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
const previous = escapeHtml(context.previousName || "Não atribuído");
|
|
||||||
const next = escapeHtml(context.nextName || "Não atribuído");
|
|
||||||
const reasonHtml = lines.length
|
|
||||||
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
|
|
||||||
: `<p>—</p>`;
|
|
||||||
return `<p><strong>Responsável atualizado:</strong> ${previous} → ${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateSubject(subject: string) {
|
function truncateSubject(subject: string) {
|
||||||
if (subject.length <= 60) return subject
|
if (subject.length <= 60) return subject
|
||||||
return `${subject.slice(0, 57)}…`
|
return `${subject.slice(0, 57)}…`
|
||||||
|
|
@ -2098,10 +2131,15 @@ export const getById = query({
|
||||||
? t.checklist.map((item) => ({
|
? t.checklist.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
text: item.text,
|
text: item.text,
|
||||||
|
description: item.description ?? undefined,
|
||||||
|
type: item.type ?? "checkbox",
|
||||||
|
options: item.options ?? undefined,
|
||||||
|
answer: item.answer ?? undefined,
|
||||||
done: item.done,
|
done: item.done,
|
||||||
required: typeof item.required === "boolean" ? item.required : true,
|
required: typeof item.required === "boolean" ? item.required : true,
|
||||||
templateId: item.templateId ? String(item.templateId) : undefined,
|
templateId: item.templateId ? String(item.templateId) : undefined,
|
||||||
templateItemId: item.templateItemId ?? undefined,
|
templateItemId: item.templateItemId ?? undefined,
|
||||||
|
templateDescription: item.templateDescription ?? undefined,
|
||||||
createdAt: item.createdAt ?? undefined,
|
createdAt: item.createdAt ?? undefined,
|
||||||
createdBy: item.createdBy ? String(item.createdBy) : undefined,
|
createdBy: item.createdBy ? String(item.createdBy) : undefined,
|
||||||
doneAt: item.doneAt ?? undefined,
|
doneAt: item.doneAt ?? undefined,
|
||||||
|
|
@ -2337,7 +2375,7 @@ export const create = mutation({
|
||||||
avatarUrl: requester.avatarUrl ?? undefined,
|
avatarUrl: requester.avatarUrl ?? undefined,
|
||||||
teams: requester.teams ?? undefined,
|
teams: requester.teams ?? undefined,
|
||||||
}
|
}
|
||||||
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
|
// Resolve a empresa primeiro para poder verificar SLA específico
|
||||||
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
|
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
|
||||||
if (!companyDoc && machineDoc?.companyId) {
|
if (!companyDoc && machineDoc?.companyId) {
|
||||||
const candidateCompany = await ctx.db.get(machineDoc.companyId)
|
const candidateCompany = await ctx.db.get(machineDoc.companyId)
|
||||||
|
|
@ -2349,6 +2387,8 @@ export const create = mutation({
|
||||||
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
|
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
|
||||||
: undefined
|
: undefined
|
||||||
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? 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
|
let checklist = manualChecklist
|
||||||
for (const templateId of args.checklistTemplateIds ?? []) {
|
for (const templateId of args.checklistTemplateIds ?? []) {
|
||||||
|
|
@ -2456,6 +2496,28 @@ export const create = mutation({
|
||||||
createdAt: now,
|
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) {
|
if (initialAssigneeId && initialAssignee) {
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: id,
|
ticketId: id,
|
||||||
|
|
@ -2647,6 +2709,49 @@ export const setChecklistItemRequired = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setChecklistItemAnswer = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
itemId: v.string(),
|
||||||
|
answer: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket não encontrado");
|
||||||
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">;
|
||||||
|
await requireTicketStaff(ctx, actorId, ticketDoc);
|
||||||
|
|
||||||
|
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
|
||||||
|
const index = checklist.findIndex((item) => item.id === itemId);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new ConvexError("Item do checklist não encontrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = checklist[index]!;
|
||||||
|
if (item.type !== "question") {
|
||||||
|
throw new ConvexError("Este item não é uma pergunta.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const normalizedAnswer = answer?.trim() ?? "";
|
||||||
|
const isDone = normalizedAnswer.length > 0;
|
||||||
|
|
||||||
|
const nextChecklist = checklist.map((it) => {
|
||||||
|
if (it.id !== itemId) return it;
|
||||||
|
if (isDone) {
|
||||||
|
return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
|
||||||
|
}
|
||||||
|
return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const removeChecklistItem = mutation({
|
export const removeChecklistItem = mutation({
|
||||||
args: {
|
args: {
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
|
|
@ -2700,6 +2805,34 @@ export const completeAllChecklistItems = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const uncompleteAllChecklistItems = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, actorId }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId);
|
||||||
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket não encontrado");
|
||||||
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">;
|
||||||
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
|
||||||
|
ensureChecklistEditor(viewer);
|
||||||
|
|
||||||
|
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
|
||||||
|
if (checklist.length === 0) return { ok: true };
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const nextChecklist = checklist.map((item) => {
|
||||||
|
if (item.done === false) return item;
|
||||||
|
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const applyChecklistTemplate = mutation({
|
export const applyChecklistTemplate = mutation({
|
||||||
args: {
|
args: {
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
|
|
@ -2851,15 +2984,19 @@ export const addComment = mutation({
|
||||||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||||
// Notificação por e-mail: comentário público para o solicitante
|
// Notificação por e-mail: comentário público para o solicitante
|
||||||
try {
|
try {
|
||||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||||
|
const snapshotEmail = requesterSnapshot?.email
|
||||||
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||||
if (typeof schedulerRunAfter === "function") {
|
if (typeof schedulerRunAfter === "function") {
|
||||||
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||||
to: snapshotEmail,
|
to: snapshotEmail,
|
||||||
|
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||||
|
userName: requesterSnapshot?.name ?? undefined,
|
||||||
ticketId: String(ticketDoc._id),
|
ticketId: String(ticketDoc._id),
|
||||||
reference: ticketDoc.reference ?? 0,
|
reference: ticketDoc.reference ?? 0,
|
||||||
subject: ticketDoc.subject ?? "",
|
subject: ticketDoc.subject ?? "",
|
||||||
|
tenantId: ticketDoc.tenantId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3090,7 +3227,18 @@ export async function resolveTicketHandler(
|
||||||
throw new ConvexError("Chamado vinculado não encontrado")
|
throw new ConvexError("Chamado vinculado não encontrado")
|
||||||
}
|
}
|
||||||
|
|
||||||
const reopenDays = resolveReopenWindowDays(reopenWindowDays)
|
// Buscar prazo de reabertura da empresa do ticket (se existir)
|
||||||
|
let companyReopenDays: number | null = null
|
||||||
|
if (ticketDoc.companyId) {
|
||||||
|
const company = await ctx.db.get(ticketDoc.companyId)
|
||||||
|
if (company && typeof company.reopenWindowDays === "number") {
|
||||||
|
companyReopenDays = company.reopenWindowDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioridade: 1) valor passado explicitamente, 2) valor da empresa, 3) padrão
|
||||||
|
const effectiveReopenDays = reopenWindowDays ?? companyReopenDays
|
||||||
|
const reopenDays = resolveReopenWindowDays(effectiveReopenDays)
|
||||||
const reopenDeadline = computeReopenDeadline(now, reopenDays)
|
const reopenDeadline = computeReopenDeadline(now, reopenDays)
|
||||||
const normalizedStatus = "RESOLVED"
|
const normalizedStatus = "RESOLVED"
|
||||||
const relatedIdList = Array.from(
|
const relatedIdList = Array.from(
|
||||||
|
|
@ -3127,16 +3275,21 @@ export async function resolveTicketHandler(
|
||||||
|
|
||||||
// Notificação por e-mail: encerramento do chamado
|
// Notificação por e-mail: encerramento do chamado
|
||||||
try {
|
try {
|
||||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
|
||||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||||
|
const email = requesterDoc?.email || requesterSnapshot?.email || null
|
||||||
|
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
|
||||||
if (email) {
|
if (email) {
|
||||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||||
if (typeof schedulerRunAfter === "function") {
|
if (typeof schedulerRunAfter === "function") {
|
||||||
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||||
to: email,
|
to: email,
|
||||||
|
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||||
|
userName,
|
||||||
ticketId: String(ticketId),
|
ticketId: String(ticketId),
|
||||||
reference: ticketDoc.reference ?? 0,
|
reference: ticketDoc.reference ?? 0,
|
||||||
subject: ticketDoc.subject ?? "",
|
subject: ticketDoc.subject ?? "",
|
||||||
|
tenantId: ticketDoc.tenantId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3373,38 +3526,6 @@ export const changeAssignee = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (normalizedReason.length > 0) {
|
|
||||||
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
|
||||||
previousName: previousAssigneeName,
|
|
||||||
nextName: nextAssigneeName,
|
|
||||||
})
|
|
||||||
const commentPlainLength = plainTextLength(commentBody)
|
|
||||||
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
|
||||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
||||||
}
|
|
||||||
const authorSnapshot: CommentAuthorSnapshot = {
|
|
||||||
name: viewerUser.name,
|
|
||||||
email: viewerUser.email,
|
|
||||||
avatarUrl: viewerUser.avatarUrl ?? undefined,
|
|
||||||
teams: viewerUser.teams ?? undefined,
|
|
||||||
}
|
|
||||||
await ctx.db.insert("ticketComments", {
|
|
||||||
ticketId,
|
|
||||||
authorId: actorId,
|
|
||||||
visibility: "INTERNAL",
|
|
||||||
body: commentBody,
|
|
||||||
authorSnapshot,
|
|
||||||
attachments: [],
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
await ctx.db.insert("ticketEvents", {
|
|
||||||
ticketId,
|
|
||||||
type: "COMMENT_ADDED",
|
|
||||||
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3734,6 +3855,8 @@ export const postChatMessage = mutation({
|
||||||
await ctx.db.patch(ticketId, { updatedAt: now })
|
await ctx.db.patch(ticketId, { updatedAt: now })
|
||||||
|
|
||||||
// Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
|
// 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() ?? ""
|
const actorRole = participant.role?.toUpperCase() ?? ""
|
||||||
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
|
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
|
||||||
const activeSession = await ctx.db
|
const activeSession = await ctx.db
|
||||||
|
|
@ -3743,12 +3866,17 @@ export const postChatMessage = mutation({
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
if (activeSession) {
|
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, {
|
await ctx.db.patch(activeSession._id, {
|
||||||
unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1,
|
unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
|
lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, messageId }
|
return { ok: true, messageId }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,86 @@ export const deleteUser = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza o avatar de um usuário.
|
||||||
|
* Passa avatarUrl como null para remover o avatar.
|
||||||
|
* Também atualiza os snapshots em comentários e tickets.
|
||||||
|
*/
|
||||||
|
export const updateAvatar = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
avatarUrl: v.union(v.string(), v.null()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, email, avatarUrl }) => {
|
||||||
|
const user = await ctx.db
|
||||||
|
.query("users")
|
||||||
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { status: "not_found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza o avatar do usuário - usa undefined para remover o campo
|
||||||
|
const normalizedAvatarUrl = avatarUrl ?? undefined
|
||||||
|
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
|
||||||
|
|
||||||
|
// Cria snapshot base sem avatarUrl se for undefined
|
||||||
|
// Isso garante que o campo seja realmente removido do snapshot
|
||||||
|
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
}
|
||||||
|
if (normalizedAvatarUrl !== undefined) {
|
||||||
|
baseSnapshot.avatarUrl = normalizedAvatarUrl
|
||||||
|
}
|
||||||
|
if (user.teams && user.teams.length > 0) {
|
||||||
|
baseSnapshot.teams = user.teams
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza snapshots em comentários
|
||||||
|
const comments = await ctx.db
|
||||||
|
.query("ticketComments")
|
||||||
|
.withIndex("by_author", (q) => q.eq("authorId", user._id))
|
||||||
|
.take(10000)
|
||||||
|
|
||||||
|
if (comments.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
comments.map(async (comment) => {
|
||||||
|
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza snapshots de requester em tickets
|
||||||
|
const requesterTickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", user._id))
|
||||||
|
.take(10000)
|
||||||
|
|
||||||
|
if (requesterTickets.length > 0) {
|
||||||
|
for (const t of requesterTickets) {
|
||||||
|
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza snapshots de assignee em tickets
|
||||||
|
const assigneeTickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", user._id))
|
||||||
|
.take(10000)
|
||||||
|
|
||||||
|
if (assigneeTickets.length > 0) {
|
||||||
|
for (const t of assigneeTickets) {
|
||||||
|
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "updated", avatarUrl: normalizedAvatarUrl }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const assignCompany = mutation({
|
export const assignCompany = mutation({
|
||||||
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Deploy Manual via VPS
|
# Deploy Manual via VPS
|
||||||
|
|
||||||
## Acesso rápido
|
## Acesso rápido
|
||||||
- Host: 31.220.78.20
|
- Host: 154.12.253.40
|
||||||
- Usuário: root
|
- Usuário: root
|
||||||
- Caminho do projeto: /srv/apps/sistema
|
- Caminho do projeto: /srv/apps/sistema
|
||||||
- Chave SSH (local): ./codex_ed25519 (chmod 600)
|
- Chave SSH (local): ./codex_ed25519 (chmod 600)
|
||||||
- Login: `ssh -i ./codex_ed25519 root@31.220.78.20`
|
- Login: `ssh -i ./codex_ed25519 root@154.12.253.40`
|
||||||
|
|
||||||
## Passo a passo resumido
|
## Passo a passo resumido
|
||||||
1. Conectar na VPS usando o comando acima.
|
1. Conectar na VPS usando o comando acima.
|
||||||
|
|
|
||||||
10
docs/DEV.md
10
docs/DEV.md
|
|
@ -1,4 +1,4 @@
|
||||||
# Guia de Desenvolvimento — 18/10/2025
|
# Guia de Desenvolvimento — 18/12/2025
|
||||||
|
|
||||||
Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções.
|
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).
|
- **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.
|
- **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun.
|
||||||
- **Next.js 16**: Projeto roda em `next@16.0.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
|
- **Next.js 16**: Projeto roda em `next@16.0.10` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
|
||||||
- **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente.
|
- **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.
|
- **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.
|
- **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)
|
## Next.js 16 (estável)
|
||||||
|
|
||||||
- Mantemos o projeto em `next@16.0.8`, com React 19 e o App Router completo.
|
- Mantemos o projeto em `next@16.0.10`, com React 19 e o App Router completo.
|
||||||
- **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas.
|
- **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`.
|
- **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
|
## Referências úteis
|
||||||
|
|
||||||
- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`.
|
- **Deploy (Swarm)**: veja `docs/OPERATIONS.md`.
|
||||||
- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`.
|
- **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.md`.
|
||||||
- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`.
|
- **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.
|
> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem.
|
||||||
|
|
|
||||||
296
docs/FORGEJO-CI-CD.md
Normal file
296
docs/FORGEJO-CI-CD.md
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
# Forgejo CI/CD - Documentacao
|
||||||
|
|
||||||
|
Este documento descreve a configuracao do Forgejo como alternativa ao GitHub Actions para CI/CD self-hosted.
|
||||||
|
|
||||||
|
## Por que Forgejo?
|
||||||
|
|
||||||
|
A partir de marco de 2026, o GitHub passara a cobrar $0.002 por minuto de execucao em self-hosted runners. O Forgejo Actions oferece a mesma experiencia visual e funcionalidade sem custo adicional.
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude Code / VS Code
|
||||||
|
|
|
||||||
|
Git local
|
||||||
|
|
|
||||||
|
git push origin main (GitHub - backup)
|
||||||
|
git push forgejo main (Forgejo - CI/CD)
|
||||||
|
|
|
||||||
|
Forgejo (git.esdrasrenan.com.br)
|
||||||
|
|
|
||||||
|
Forgejo Actions (dispara automaticamente)
|
||||||
|
|
|
||||||
|
Forgejo Runner (VPS)
|
||||||
|
|
|
||||||
|
Docker Swarm deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fluxo:** Push para ambos os remotes. O push para `forgejo` dispara o CI/CD.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push para ambos (recomendado)
|
||||||
|
git push origin main && git push forgejo main
|
||||||
|
|
||||||
|
# Ou use o alias configurado
|
||||||
|
git push-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs e Credenciais
|
||||||
|
|
||||||
|
| Servico | URL | Usuario |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| Forgejo UI | https://git.esdrasrenan.com.br | esdras |
|
||||||
|
| Forgejo SSH | git@git.esdrasrenan.com.br:2222 | - |
|
||||||
|
| Actions | https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions | - |
|
||||||
|
|
||||||
|
**Senha inicial:** `ForgejoAdmin2025!` (altere apos primeiro acesso)
|
||||||
|
|
||||||
|
## Estrutura de Arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
projeto/
|
||||||
|
├── .forgejo/
|
||||||
|
│ └── workflows/
|
||||||
|
│ ├── ci-cd-web-desktop.yml # Deploy principal (VPS + Convex)
|
||||||
|
│ └── quality-checks.yml # Lint, test, build
|
||||||
|
├── .github/
|
||||||
|
│ └── workflows/ # Workflows originais do GitHub
|
||||||
|
│ └── ...
|
||||||
|
└── forgejo/
|
||||||
|
├── stack.yml # Stack Docker do Forgejo
|
||||||
|
└── setup-runner.sh # Script de setup do runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuracao na VPS
|
||||||
|
|
||||||
|
### Forgejo Server
|
||||||
|
|
||||||
|
Rodando como servico Docker Swarm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Localização do stack
|
||||||
|
/srv/forgejo/stack.yml
|
||||||
|
|
||||||
|
# Comandos uteis
|
||||||
|
docker service ls --filter "name=forgejo"
|
||||||
|
docker service logs forgejo_forgejo --tail 100
|
||||||
|
docker stack deploy -c /srv/forgejo/stack.yml forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forgejo Runner
|
||||||
|
|
||||||
|
Rodando como servico systemd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Localização
|
||||||
|
/srv/forgejo-runner/
|
||||||
|
|
||||||
|
# Arquivos
|
||||||
|
/srv/forgejo-runner/forgejo-runner # Binario
|
||||||
|
/srv/forgejo-runner/config.yaml # Configuracao
|
||||||
|
/srv/forgejo-runner/.runner # Registro
|
||||||
|
|
||||||
|
# Comandos uteis
|
||||||
|
systemctl status forgejo-runner
|
||||||
|
systemctl restart forgejo-runner
|
||||||
|
journalctl -u forgejo-runner -f
|
||||||
|
|
||||||
|
# Labels do runner
|
||||||
|
- ubuntu-latest:docker://node:20-bookworm
|
||||||
|
- self-hosted:host
|
||||||
|
- linux:host
|
||||||
|
- vps:host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fluxo de Trabalho
|
||||||
|
|
||||||
|
O repositorio no Forgejo recebe pushes diretos (nao e mais um mirror).
|
||||||
|
|
||||||
|
### Uso diario
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trabalhe normalmente
|
||||||
|
git add .
|
||||||
|
git commit -m "sua mensagem"
|
||||||
|
|
||||||
|
# Push para GitHub (backup) e Forgejo (CI/CD)
|
||||||
|
git push origin main && git push forgejo main
|
||||||
|
|
||||||
|
# Acompanhe o CI/CD em:
|
||||||
|
# https://git.esdrasrenan.com.br/esdras/sistema-de-chamados/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configurar alias (opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Adicionar alias para push em ambos
|
||||||
|
git config alias.push-all '!git push origin main && git push forgejo main'
|
||||||
|
|
||||||
|
# Usar:
|
||||||
|
git push-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflows Disponiveis
|
||||||
|
|
||||||
|
### ci-cd-web-desktop.yml
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
- Push na branch `main`
|
||||||
|
- Tags `v*.*.*`
|
||||||
|
- workflow_dispatch (manual)
|
||||||
|
|
||||||
|
Jobs:
|
||||||
|
1. **changes** - Detecta arquivos alterados
|
||||||
|
2. **deploy** - Deploy na VPS (Next.js + Docker Swarm, usando Bun)
|
||||||
|
3. **convex_deploy** - Deploy das functions Convex
|
||||||
|
4. ~~**desktop_release**~~ - Build do app desktop (comentado - sem runner Windows)
|
||||||
|
|
||||||
|
### quality-checks.yml
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
- Push na branch `main`
|
||||||
|
- Pull requests para `main`
|
||||||
|
|
||||||
|
Jobs:
|
||||||
|
1. **lint-test-build** - Lint, testes e build
|
||||||
|
|
||||||
|
## Diferenca do GitHub Actions
|
||||||
|
|
||||||
|
Os workflows do Forgejo sao quase identicos aos do GitHub Actions. Principais diferencas:
|
||||||
|
|
||||||
|
1. **Localizacao:** `.forgejo/workflows/` em vez de `.github/workflows/`
|
||||||
|
|
||||||
|
2. **Actions URL:** Usar `https://github.com/` prefixo nas actions
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Forgejo Actions
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **runs-on:** Usar labels do self-hosted runner em vez de `ubuntu-latest`
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions (hosted runner)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Forgejo Actions (self-hosted)
|
||||||
|
runs-on: [ self-hosted, linux, vps ]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Secrets:** Configurar em Settings > Actions > Secrets no Forgejo
|
||||||
|
|
||||||
|
## Manutencao
|
||||||
|
|
||||||
|
### Atualizar Forgejo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@154.12.253.40
|
||||||
|
cd /srv/forgejo
|
||||||
|
# Editar stack.yml para nova versao da imagem
|
||||||
|
docker stack deploy -c stack.yml forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Atualizar Runner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@154.12.253.40
|
||||||
|
cd /srv/forgejo-runner
|
||||||
|
systemctl stop forgejo-runner
|
||||||
|
|
||||||
|
# Baixar nova versao
|
||||||
|
RUNNER_VERSION="6.2.2" # ajustar versao
|
||||||
|
curl -sL -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
|
||||||
|
chmod +x forgejo-runner
|
||||||
|
|
||||||
|
systemctl start forgejo-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Re-registrar Runner
|
||||||
|
|
||||||
|
Se o runner perder a conexao:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@154.12.253.40
|
||||||
|
cd /srv/forgejo-runner
|
||||||
|
|
||||||
|
# Gerar novo token no Forgejo
|
||||||
|
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
|
||||||
|
/usr/local/bin/gitea --config /data/gitea/conf/app.ini actions generate-runner-token
|
||||||
|
|
||||||
|
# Re-registrar
|
||||||
|
systemctl stop forgejo-runner
|
||||||
|
rm .runner
|
||||||
|
./forgejo-runner register \
|
||||||
|
--instance https://git.esdrasrenan.com.br \
|
||||||
|
--token "NOVO_TOKEN" \
|
||||||
|
--name "vps-runner" \
|
||||||
|
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
|
||||||
|
--no-interactive
|
||||||
|
systemctl start forgejo-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup do volume do Forgejo
|
||||||
|
docker run --rm -v forgejo_forgejo_data:/data -v /backup:/backup alpine \
|
||||||
|
tar czf /backup/forgejo-backup-$(date +%Y%m%d).tar.gz /data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Runner nao aparece online
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar status
|
||||||
|
systemctl status forgejo-runner
|
||||||
|
journalctl -u forgejo-runner --no-pager -n 50
|
||||||
|
|
||||||
|
# Verificar conectividade
|
||||||
|
curl -s https://git.esdrasrenan.com.br/api/healthz
|
||||||
|
|
||||||
|
# Se o runner mostrar erro "404 Not Found" apos reinicio do Forgejo:
|
||||||
|
systemctl restart forgejo-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow nao dispara apos push
|
||||||
|
|
||||||
|
1. Verificar se o arquivo esta em `.forgejo/workflows/`
|
||||||
|
2. Verificar se Actions esta habilitado no repositorio (Settings > Actions)
|
||||||
|
3. Verificar se o runner esta online (Settings > Actions > Runners)
|
||||||
|
4. **Regenerar hooks do repositorio:**
|
||||||
|
```bash
|
||||||
|
docker exec -u 1000:1000 $(docker ps -q --filter "name=forgejo_forgejo") \
|
||||||
|
/usr/local/bin/gitea admin regenerate hooks --config /data/gitea/conf/app.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro de LevelDB Lock (queue nao inicia)
|
||||||
|
|
||||||
|
Se o Forgejo mostrar erro `unable to lock level db at /data/gitea/queues/common`:
|
||||||
|
|
||||||
|
1. O stack.yml ja usa `FORGEJO__queue__TYPE=channel` para evitar esse problema
|
||||||
|
2. Se o erro persistir, limpe o diretorio de queues:
|
||||||
|
```bash
|
||||||
|
docker exec $(docker ps -q --filter "name=forgejo_forgejo") \
|
||||||
|
rm -rf /data/gitea/queues/*
|
||||||
|
docker service update --force forgejo_forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro de permissao no deploy
|
||||||
|
|
||||||
|
O runner precisa de acesso ao Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar grupo docker
|
||||||
|
groups runner
|
||||||
|
# Adicionar se necessario
|
||||||
|
usermod -aG docker runner
|
||||||
|
systemctl restart forgejo-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [Forgejo Documentation](https://forgejo.org/docs/)
|
||||||
|
- [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/)
|
||||||
|
- [Forgejo Runner](https://code.forgejo.org/forgejo/runner)
|
||||||
166
docs/LOCAL-DEV.md
Normal file
166
docs/LOCAL-DEV.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Desenvolvimento Local
|
||||||
|
|
||||||
|
Guia para rodar o projeto localmente conectando aos dados de producao.
|
||||||
|
|
||||||
|
## Pre-requisitos
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh/) 1.3+
|
||||||
|
- [Docker](https://www.docker.com/) (para PostgreSQL)
|
||||||
|
- Node.js 20+ (opcional, usado pelo tsx)
|
||||||
|
|
||||||
|
## 1. Subir o PostgreSQL
|
||||||
|
|
||||||
|
O sistema usa PostgreSQL para autenticacao (Better Auth). Os dados de tickets ficam no Convex.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name postgres-chamados \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_PASSWORD=dev \
|
||||||
|
-e POSTGRES_DB=sistema_chamados \
|
||||||
|
postgres:18
|
||||||
|
```
|
||||||
|
|
||||||
|
Para verificar se esta rodando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps | grep postgres-chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
Para parar/iniciar posteriormente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop postgres-chamados
|
||||||
|
docker start postgres-chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Configurar variaveis de ambiente
|
||||||
|
|
||||||
|
O arquivo `.env.local` ja vem configurado para desenvolvimento local apontando para o Convex de producao:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# URLs locais
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Convex de producao (dados reais)
|
||||||
|
NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br
|
||||||
|
CONVEX_INTERNAL_URL=https://convex.esdrasrenan.com.br
|
||||||
|
|
||||||
|
# PostgreSQL local (apenas autenticacao)
|
||||||
|
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Instalar dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Gerar cliente Prisma e aplicar schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run prisma:generate
|
||||||
|
bunx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Criar usuarios de desenvolvimento
|
||||||
|
|
||||||
|
O seed cria usuarios locais para autenticacao:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credenciais padrao
|
||||||
|
|
||||||
|
| Usuario | Email | Senha | Role |
|
||||||
|
|---------------|----------------------|------------|-------|
|
||||||
|
| Administrador | `admin@sistema.dev` | `admin123` | admin |
|
||||||
|
| Agentes | `*@rever.com.br` | `agent123` | agent |
|
||||||
|
|
||||||
|
## 6. Iniciar o servidor de desenvolvimento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev:bun
|
||||||
|
```
|
||||||
|
|
||||||
|
Acesse: http://localhost:3000
|
||||||
|
|
||||||
|
## Arquitetura Local vs Producao
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ DESENVOLVIMENTO LOCAL │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ localhost:3000 (Next.js) │
|
||||||
|
│ │ │
|
||||||
|
│ ├──► PostgreSQL local (porta 5432) │
|
||||||
|
│ │ └── Autenticacao (Better Auth) │
|
||||||
|
│ │ └── Usuarios, sessoes, contas │
|
||||||
|
│ │ │
|
||||||
|
│ └──► convex.esdrasrenan.com.br (remoto) │
|
||||||
|
│ └── Dados de producao │
|
||||||
|
│ └── Tickets, empresas, filas, etc. │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos uteis
|
||||||
|
|
||||||
|
| Comando | Descricao |
|
||||||
|
|---------|-----------|
|
||||||
|
| `bun run dev:bun` | Inicia servidor de desenvolvimento com Turbopack |
|
||||||
|
| `bun run build:bun` | Build de producao |
|
||||||
|
| `bun run lint` | Verificar codigo com ESLint |
|
||||||
|
| `bun test` | Rodar testes |
|
||||||
|
| `bunx prisma studio` | Interface visual do banco de dados |
|
||||||
|
|
||||||
|
## Solucao de problemas
|
||||||
|
|
||||||
|
### Erro de conexao com PostgreSQL
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: P1001: Can't reach database server at localhost:5432
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solucao:** Verifique se o container Docker esta rodando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker start postgres-chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro de migracao (tipo DATETIME)
|
||||||
|
|
||||||
|
Se aparecer erro sobre tipo `DATETIME` ao rodar migrations, use `db push` em vez de `migrate`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx prisma db push --accept-data-loss
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usuario nao consegue logar
|
||||||
|
|
||||||
|
Os usuarios de autenticacao ficam no PostgreSQL local, nao no Convex. Rode o seed novamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limpar banco e recriar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop postgres-chamados
|
||||||
|
docker rm postgres-chamados
|
||||||
|
docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||||
|
bunx prisma db push
|
||||||
|
DATABASE_URL="postgresql://postgres:dev@localhost:5432/sistema_chamados" bun tsx scripts/seed-auth.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proximos passos
|
||||||
|
|
||||||
|
- Para deploy em producao, consulte `docs/OPERACAO-PRODUCAO.md`
|
||||||
|
- Para configuracao de SMTP, consulte `docs/SMTP.md`
|
||||||
|
- Para testes automatizados, consulte `docs/testes-vitest.md`
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
Este índice consolida a documentação viva e move conteúdos históricos para um arquivo. O objetivo é simplificar o onboarding e a operação.
|
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
|
## Visão Geral
|
||||||
|
- **Desenvolvimento local**: `docs/LOCAL-DEV.md` (setup rapido para rodar localmente)
|
||||||
- Operações (produção): `docs/operations.md`
|
- Operações (produção): `docs/operations.md`
|
||||||
- Guia de desenvolvimento: `docs/DEV.md`
|
- Guia de desenvolvimento: `docs/DEV.md`
|
||||||
- Desktop (Tauri):
|
- Desktop (Tauri):
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
- 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
|
## Como acessar tickets antigos sem perda
|
||||||
- Base quente: Prisma (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
|
- Base quente: Prisma (PostgreSQL) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
|
||||||
- Se um dia for preciso offload (ex.: >50k tickets):
|
- Se um dia for preciso offload (ex.: >50k tickets):
|
||||||
- Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat).
|
- 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`).
|
- 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)
|
## 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"`
|
- 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"`
|
- Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"`
|
||||||
- Alvos: <100-200 MB para o SQLite e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
|
- Alvos: <100-200 MB para o SQLite do Convex e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
|
||||||
|
|
||||||
## Estado atual e proximos passos
|
## Estado atual e proximos passos
|
||||||
- Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes.
|
- Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes.
|
||||||
|
|
|
||||||
252
docs/SETUP.md
Normal file
252
docs/SETUP.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Setup em Novo Computador
|
||||||
|
|
||||||
|
Guia rapido para configurar o ambiente de desenvolvimento em uma nova maquina.
|
||||||
|
|
||||||
|
## Pre-requisitos
|
||||||
|
|
||||||
|
- **Git** instalado
|
||||||
|
- **Bun** 1.3+ ([bun.sh](https://bun.sh))
|
||||||
|
- **Docker** (para PostgreSQL local)
|
||||||
|
- **Node.js** 20+ (opcional, para algumas ferramentas)
|
||||||
|
|
||||||
|
### Instalar Bun (se ainda nao tiver)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS/WSL
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configurar Autenticacao (Repositorio Privado)
|
||||||
|
|
||||||
|
Se o repositorio for privado, configure autenticacao SSH antes de clonar.
|
||||||
|
|
||||||
|
### Opcao 1: SSH Key (Recomendado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Gerar chave SSH (se nao tiver)
|
||||||
|
ssh-keygen -t ed25519 -C "seu-email@exemplo.com"
|
||||||
|
# Pressione Enter para aceitar o local padrao
|
||||||
|
# Defina uma senha ou deixe em branco
|
||||||
|
|
||||||
|
# 2. Copiar a chave publica
|
||||||
|
# Linux/macOS/WSL:
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# Windows (PowerShell):
|
||||||
|
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub
|
||||||
|
|
||||||
|
# Windows (CMD):
|
||||||
|
type %USERPROFILE%\.ssh\id_ed25519.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adicionar a chave nos servicos:**
|
||||||
|
- **GitHub:** Settings > SSH and GPG keys > New SSH key
|
||||||
|
- **Forgejo:** Settings > SSH / GPG Keys > Add Key
|
||||||
|
|
||||||
|
### Opcao 2: Personal Access Token (PAT)
|
||||||
|
|
||||||
|
1. **GitHub:** Settings > Developer settings > Personal access tokens > Tokens (classic)
|
||||||
|
2. Gerar token com permissao `repo`
|
||||||
|
3. Usar o token como senha quando o git pedir
|
||||||
|
|
||||||
|
Para salvar o token (nao precisar digitar toda vez):
|
||||||
|
```bash
|
||||||
|
git config --global credential.helper store
|
||||||
|
# Proximo push/pull vai pedir usuario e token, e salvar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Rapido
|
||||||
|
|
||||||
|
### 1. Clonar o repositorio
|
||||||
|
|
||||||
|
**Repositorio publico (HTTPS):**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/esdrasrenan/sistema-de-chamados.git
|
||||||
|
cd sistema-de-chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repositorio privado (SSH):**
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:esdrasrenan/sistema-de-chamados.git
|
||||||
|
cd sistema-de-chamados
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou se ja tiver o repositorio:
|
||||||
|
```bash
|
||||||
|
cd sistema-de-chamados
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar remotes (para CI/CD)
|
||||||
|
|
||||||
|
**Repositorio publico (HTTPS):**
|
||||||
|
```bash
|
||||||
|
git remote add forgejo https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repositorio privado (SSH):**
|
||||||
|
```bash
|
||||||
|
# Mudar origin para SSH (se clonou via HTTPS)
|
||||||
|
git remote set-url origin git@github.com:esdrasrenan/sistema-de-chamados.git
|
||||||
|
|
||||||
|
# Adicionar forgejo via SSH (porta 2222)
|
||||||
|
git remote add forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verificar remotes:**
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
# Deve mostrar (exemplo com SSH):
|
||||||
|
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (fetch)
|
||||||
|
# origin git@github.com:esdrasrenan/sistema-de-chamados.git (push)
|
||||||
|
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (fetch)
|
||||||
|
# forgejo ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git (push)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Instalar dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configurar banco de dados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Subir PostgreSQL via Docker
|
||||||
|
docker run -d \
|
||||||
|
--name postgres-dev \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_PASSWORD=dev \
|
||||||
|
-e POSTGRES_DB=sistema_chamados \
|
||||||
|
postgres:18
|
||||||
|
|
||||||
|
# Criar arquivo .env
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edite o `.env` e configure:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||||
|
BETTER_AUTH_SECRET=sua-chave-secreta-aqui
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Inicializar o banco
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gerar cliente Prisma
|
||||||
|
bun run prisma:generate
|
||||||
|
|
||||||
|
# Criar tabelas no banco
|
||||||
|
bunx prisma db push
|
||||||
|
|
||||||
|
# Popular dados iniciais
|
||||||
|
bun run auth:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Rodar o projeto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev:bun
|
||||||
|
```
|
||||||
|
|
||||||
|
Acesse: http://localhost:3000
|
||||||
|
|
||||||
|
**Credenciais padrao:** `admin@sistema.dev` / `admin123`
|
||||||
|
|
||||||
|
## Comandos Uteis
|
||||||
|
|
||||||
|
| Comando | Descricao |
|
||||||
|
|---------|-----------|
|
||||||
|
| `bun run dev:bun` | Iniciar servidor de desenvolvimento |
|
||||||
|
| `bun run build:bun` | Build de producao |
|
||||||
|
| `bun run lint` | Verificar codigo (ESLint) |
|
||||||
|
| `bun test` | Rodar testes |
|
||||||
|
| `bun run prisma:generate` | Gerar cliente Prisma |
|
||||||
|
| `bunx prisma studio` | Interface visual do banco |
|
||||||
|
|
||||||
|
## Fluxo de Trabalho com Git
|
||||||
|
|
||||||
|
### Push para ambos os remotes (recomendado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fazer alteracoes
|
||||||
|
git add .
|
||||||
|
git commit -m "sua mensagem"
|
||||||
|
|
||||||
|
# Push para GitHub (backup) e Forgejo (CI/CD)
|
||||||
|
git push origin main && git push forgejo main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configurar alias para push duplo (opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Criar alias
|
||||||
|
git config alias.push-all '!git push origin main && git push forgejo main'
|
||||||
|
|
||||||
|
# Usar
|
||||||
|
git push-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erro: "bun: command not found"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Adicionar Bun ao PATH
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
# Adicionar permanentemente ao ~/.bashrc ou ~/.zshrc
|
||||||
|
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro: Prisma "P2021" / tabelas nao existem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx prisma db push
|
||||||
|
bun run auth:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro: Lockfile desatualizado
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL nao conecta
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar se o container esta rodando
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Se nao estiver, iniciar
|
||||||
|
docker start postgres-dev
|
||||||
|
|
||||||
|
# Ou recriar
|
||||||
|
docker rm -f postgres-dev
|
||||||
|
docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||||
|
```
|
||||||
|
|
||||||
|
## Convex (Backend de Tempo Real)
|
||||||
|
|
||||||
|
Para desenvolvimento com Convex local:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Convex dev server
|
||||||
|
bun run convex:dev:bun
|
||||||
|
|
||||||
|
# Terminal 2: Next.js
|
||||||
|
bun run dev:bun
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mais Informacoes
|
||||||
|
|
||||||
|
- **Desenvolvimento detalhado:** `docs/DEV.md`
|
||||||
|
- **Deploy e operacoes:** `docs/OPERATIONS.md`
|
||||||
|
- **CI/CD Forgejo:** `docs/FORGEJO-CI-CD.md`
|
||||||
15
docs/SMTP.md
15
docs/SMTP.md
|
|
@ -15,14 +15,17 @@ Configuracao do servidor de email para envio de notificacoes do sistema.
|
||||||
|
|
||||||
## Variaveis de Ambiente
|
## Variaveis de Ambiente
|
||||||
|
|
||||||
|
Nomes usados pelo sistema (conforme `src/lib/env.ts`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SMTP_HOST=smtp.c.inova.com.br
|
SMTP_ADDRESS=smtp.c.inova.com.br
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_TLS=false
|
||||||
SMTP_USER=envio@rever.com.br
|
SMTP_ENABLE_STARTTLS_AUTO=true
|
||||||
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
|
SMTP_USERNAME=envio@rever.com.br
|
||||||
SMTP_FROM_NAME=Sistema de Chamados
|
SMTP_PASSWORD=CAAJQm6ZT6AUdhXRTDYu
|
||||||
SMTP_FROM_EMAIL=envio@rever.com.br
|
SMTP_DOMAIN=rever.com.br
|
||||||
|
MAILER_SENDER_EMAIL=Sistema de Chamados <envio@rever.com.br>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exemplo de Uso (Nodemailer)
|
## Exemplo de Uso (Nodemailer)
|
||||||
|
|
|
||||||
54
docs/alteracoes-producao-2025-12-18.md
Normal file
54
docs/alteracoes-producao-2025-12-18.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Alteracoes de producao - 2025-12-18
|
||||||
|
|
||||||
|
Este documento registra as mudancas aplicadas na VPS para estabilizar o ambiente e padronizar o uso do PostgreSQL 18.
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
- Migracao do banco principal do sistema para o servico `postgres18`.
|
||||||
|
- Desativacao do servico `postgres` (pg16) no Swarm.
|
||||||
|
- Convex backend fixado na tag `ghcr.io/get-convex/convex-backend:6690a911bced1e5e516eafc0409a7239fb6541bb`.
|
||||||
|
- `CONVEX_INTERNAL_URL` ajustado para o endpoint publico, evitando falhas de DNS interno (`ENOTFOUND sistema_convex_backend`).
|
||||||
|
- Tratamento explicito para tokens revogados/expirados/invalidos nas rotas `/api/machines/*` e chat.
|
||||||
|
- Limpeza de documento legado no Convex (`liveChatSessions` id `pd71bvfbxx7th3npdj519hcf3s7xbe2j`).
|
||||||
|
|
||||||
|
## Backups gerados
|
||||||
|
- `/root/pg-backups/sistema_chamados_pg16_20251218215925.dump`
|
||||||
|
- `/root/pg-backups/sistema_chamados_pg18_20251218215925.dump`
|
||||||
|
- Convex: `/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717`
|
||||||
|
- Observacao: foi gerado um arquivo extra `db.sqlite3.backup-` (sem timestamp) por comando incorreto.
|
||||||
|
|
||||||
|
## Procedimento (principais comandos)
|
||||||
|
```
|
||||||
|
# 1) Backup dos bancos
|
||||||
|
docker exec -u postgres <pg16> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg16_20251218215925.dump
|
||||||
|
docker exec -u postgres <pg18> pg_dump -Fc -d sistema_chamados -f /tmp/sistema_chamados_pg18_20251218215925.dump
|
||||||
|
|
||||||
|
# 2) Parar o web durante a migracao
|
||||||
|
docker service scale sistema_web=0
|
||||||
|
|
||||||
|
# 3) Restaurar dump do pg16 no pg18
|
||||||
|
docker exec -u postgres <pg18> psql -c "DROP DATABASE IF EXISTS sistema_chamados;"
|
||||||
|
docker exec -u postgres <pg18> psql -c "CREATE DATABASE sistema_chamados OWNER sistema;"
|
||||||
|
docker cp /root/pg-backups/sistema_chamados_pg16_20251218215925.dump <pg18>:/tmp/sistema_chamados_restore.dump
|
||||||
|
docker exec -u postgres <pg18> pg_restore -d sistema_chamados -Fc /tmp/sistema_chamados_restore.dump
|
||||||
|
|
||||||
|
# 4) Atualizar stack (com variaveis exportadas)
|
||||||
|
set -a; . /srv/apps/sistema/.env; set +a
|
||||||
|
docker stack deploy --with-registry-auth -c /srv/apps/sistema/stack.yml sistema
|
||||||
|
|
||||||
|
# 5) Desativar pg16
|
||||||
|
docker service scale postgres=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ajustes em stack.yml
|
||||||
|
- `DATABASE_URL` apontando para `postgres18:5432`.
|
||||||
|
- `CONVEX_INTERNAL_URL` apontando para `https://convex.esdrasrenan.com.br`.
|
||||||
|
- Imagem do Convex ajustada para a tag acima.
|
||||||
|
|
||||||
|
## Resultado
|
||||||
|
- `sistema_web` voltou com 2 replicas saudaveis.
|
||||||
|
- `sistema_convex_backend` rodando na tag informada.
|
||||||
|
- `postgres` (pg16) desativado no Swarm.
|
||||||
|
- Healthcheck OK: `GET /api/health` e `GET /version`.
|
||||||
|
|
||||||
|
## Observacoes operacionais
|
||||||
|
- O deploy do stack precisa de variaveis exportadas do `.env`. Sem isso, `NEXT_PUBLIC_*` fica vazio e o `POSTGRES_PASSWORD` nao e propagado, causando `P1000` no Prisma.
|
||||||
34
docs/alteracoes-producao-2025-12-19.md
Normal file
34
docs/alteracoes-producao-2025-12-19.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# 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).
|
||||||
|
- Adicionada excecao de roteamento para `POST /api/storage/upload` (uploads do Storage) e `CONVEX_SITE_ORIGIN` ajustado para o app (`NEXT_PUBLIC_APP_URL`) para liberar CORS no frontend.
|
||||||
|
- Prioridades de roteamento ajustadas:
|
||||||
|
- `sistema_convex_api_json` (priority 100)
|
||||||
|
- `sistema_convex_api_upload` (priority 90)
|
||||||
|
- `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).
|
||||||
|
|
@ -112,7 +112,39 @@ Critérios de sucesso:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Referências rápidas
|
## 6. Registro de alterações manuais
|
||||||
|
|
||||||
|
### 2025-12-18 — liveChatSessions com versão legada (shape_inference)
|
||||||
|
|
||||||
|
Motivo: logs do Convex mostravam `shape_inference` recorrente apontando para o documento
|
||||||
|
`pd71bvfbxx7th3npdj519hcf3s7xbe2j` (sessão de chat antiga com status `ACTIVE` em versão histórica).
|
||||||
|
|
||||||
|
Comandos executados:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Parar Convex
|
||||||
|
docker service scale sistema_convex_backend=0
|
||||||
|
|
||||||
|
# 2) Backup
|
||||||
|
cp /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3 \
|
||||||
|
/var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3.backup-20251218165717
|
||||||
|
|
||||||
|
# 3) Remover versões antigas do documento (mantendo a mais recente)
|
||||||
|
docker run --rm -v sistema_convex_data:/convex/data nouchka/sqlite3 /convex/data/db.sqlite3 \
|
||||||
|
"DELETE FROM documents \
|
||||||
|
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j' \
|
||||||
|
AND ts < (SELECT MAX(ts) FROM documents \
|
||||||
|
WHERE json_extract(json_value, '$._id') = 'pd71bvfbxx7th3npdj519hcf3s7xbe2j');"
|
||||||
|
|
||||||
|
# 4) Subir Convex
|
||||||
|
docker service scale sistema_convex_backend=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado: versões antigas do documento foram removidas e os erros de `shape_inference` pararam após o restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Referências rápidas
|
||||||
|
|
||||||
- Volume Convex: `sistema_convex_data`
|
- Volume Convex: `sistema_convex_data`
|
||||||
- Banco: `/convex/data/db.sqlite3`
|
- Banco: `/convex/data/db.sqlite3`
|
||||||
|
|
@ -122,4 +154,4 @@ Critérios de sucesso:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Última revisão: **18/11/2025** — sanado por remoção dos registros incompatíveis e rerun bem-sucedido do export `gg20vw5b479d9a2jprjpe3pxg57vk9wa`.
|
Última revisão: **18/12/2025** — limpeza da versão legada de `liveChatSessions` (`pd71bvfbxx7th3npdj519hcf3s7xbe2j`) e restart do Convex.
|
||||||
|
|
|
||||||
51
docs/diagnostico-chat-desktop-2025-12-19.md
Normal file
51
docs/diagnostico-chat-desktop-2025-12-19.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Diagnostico — Chat do desktop (2025-12-19)
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Relato de instabilidade no chat do desktop (Raven): mensagens enviadas pela web nao chegavam ao app, e com multiplas sessoes a janela travava/nao abria.
|
||||||
|
|
||||||
|
## Evidencias coletadas
|
||||||
|
- `tickets:getById` confirmou ticket #41048 vinculado a maquina `jn7fc2d5dd8f1qw340ya092k6d7xjrps`, chat habilitado e maquina online.
|
||||||
|
- `liveChat:getTicketSession` nao tinha sessao ativa antes do teste.
|
||||||
|
- Teste ponta a ponta via Convex:
|
||||||
|
- `liveChat:startSession` + `tickets:postChatMessage` criaram sessao e mensagem.
|
||||||
|
- `liveChat:checkMachineUpdates` retornou `hasActiveSessions=true` e `unreadCount=1`.
|
||||||
|
- `liveChat:listMachineMessages` retornou a nova mensagem.
|
||||||
|
- `POST /api/machines/chat/poll` confirmou o mesmo unread.
|
||||||
|
- Traefik (VPS): nao ha chamadas do desktop para `/api/machines/chat/*` nem `raven-chat/1.0` nas ultimas horas.
|
||||||
|
- Logs locais do desktop:
|
||||||
|
- `raven-agent.log` sem entradas `[CHAT DEBUG]`.
|
||||||
|
- `app.log` sem `chat:started`.
|
||||||
|
- Com duas sessoes ativas, o log parou em:
|
||||||
|
- `[CMD] open_chat_window called...`
|
||||||
|
- `[WINDOW] ... build() inicio`
|
||||||
|
- sem `build() OK` / `open_chat_window result`, indicando travamento na criacao da janela quando chamada via comando.
|
||||||
|
|
||||||
|
## Causa raiz
|
||||||
|
O desktop nao estava iniciando o runtime de chat.
|
||||||
|
Em `apps/desktop/src/main.tsx`, o `invoke("start_chat_polling", ...)` enviava `base_url` e `convex_url` em snake_case. No Tauri v2, o mapeamento esperado e camelCase (`baseUrl`, `convexUrl`). Com isso, o comando falha na desserializacao dos args e o chat nao inicia (sem polling/WebSocket), resultando em nenhuma mensagem chegando ao app.
|
||||||
|
|
||||||
|
Em cenarios com multiplas sessoes, a abertura do segundo chat via hub usa o comando `open_chat_window` (JS). Esse comando era sincrono e rodava no thread principal; ao criar uma nova janela (`WebviewWindowBuilder::build`), a execucao travava e a janela nao concluia o build, congelando o chat no desktop.
|
||||||
|
|
||||||
|
## Correcoes aplicadas
|
||||||
|
- Ajustado `invoke("start_chat_polling")` para usar `baseUrl` e `convexUrl` (camelCase).
|
||||||
|
- Tornado `open_chat_window` e `open_hub_window` assíncronos, executando em `spawn_blocking` para evitar bloqueio do thread principal ao criar novas janelas de chat.
|
||||||
|
- Quando o chat esta aberto e no fim da conversa, o desktop marca automaticamente mensagens como lidas (evita badge preso).
|
||||||
|
- Ao abrir um chat (foco), outras janelas de chat sao ocultadas e o hub e escondido para evitar sobreposicao.
|
||||||
|
- Ao minimizar um chat, outras janelas de chat abertas sao ocultadas automaticamente.
|
||||||
|
|
||||||
|
## Arquivos alterados
|
||||||
|
- `apps/desktop/src/main.tsx`
|
||||||
|
- `apps/desktop/src-tauri/src/lib.rs`
|
||||||
|
- `apps/desktop/src-tauri/src/chat.rs`
|
||||||
|
- `apps/desktop/src/chat/ChatWidget.tsx`
|
||||||
|
|
||||||
|
## Testes recomendados
|
||||||
|
- `bun run lint`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run build:bun`
|
||||||
|
|
||||||
|
## Validacao operativa (pos-build)
|
||||||
|
1. Abrir o Raven com a maquina online.
|
||||||
|
2. Enviar mensagem no ticket #41048.
|
||||||
|
3. Confirmar em `raven-agent.log` a sequencia `[CHAT DEBUG] Iniciando sistema de chat` e eventos `chat:started` em `app.log`.
|
||||||
|
4. Verificar no Traefik chamadas `/api/machines/chat/poll` ou conexoes WS do Convex com origin `http://tauri.localhost`.
|
||||||
|
|
@ -14,6 +14,18 @@ export type TicketCardData = {
|
||||||
assigneeName?: string | null
|
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) {
|
function badge(label: string, bg: string, color: string) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
@ -76,7 +88,8 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCard({ ticket }: { ticket: TicketCardData }) {
|
/** @deprecated Use TicketCard with props instead */
|
||||||
|
export function TicketCardLegacy({ ticket }: { ticket: TicketCardData }) {
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -100,3 +113,90 @@ export function TicketCard({ ticket }: { ticket: TicketCardData }) {
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TicketCard(props: TicketCardProps) {
|
||||||
|
const { ticketNumber, ticketTitle, status, priority, category, subcategory, companyName, requesterName, assigneeName } = props
|
||||||
|
const categoryLabel = category && subcategory ? `${category} / ${subcategory}` : category ?? subcategory ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #f1f5f9" }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Chamado #{ticketNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "4px 0 0 0", fontSize: "16px", fontWeight: 700, color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
{status ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Status
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px" }}>{statusBadge(status)}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{priority ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Prioridade
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px" }}>{priorityBadge(priority)}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{categoryLabel ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Categoria
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{categoryLabel}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{companyName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Empresa
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{companyName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{requesterName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Solicitante
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{requesterName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{assigneeName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Responsavel
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{assigneeName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
import { RavenEmailLayout } from "./_components/layout"
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
import { EMAIL_COLORS } from "./_components/tokens"
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
import { TicketCard, type TicketCardData } from "./_components/ticket-card"
|
import { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card"
|
||||||
import { normalizeTextToParagraphs } from "./_components/utils"
|
import { normalizeTextToParagraphs } from "./_components/utils"
|
||||||
|
|
||||||
export type AutomationEmailProps = {
|
export type AutomationEmailProps = {
|
||||||
|
|
@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) {
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TicketCard ticket={props.ticket} />
|
<TicketCardLegacy ticket={props.ticket} />
|
||||||
|
|
||||||
<Section style={{ marginTop: "18px" }}>
|
<Section style={{ marginTop: "18px" }}>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
132
emails/invite-email.tsx
Normal file
132
emails/invite-email.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type InviteEmailProps = {
|
||||||
|
inviterName: string
|
||||||
|
roleName: string
|
||||||
|
companyName?: string | null
|
||||||
|
inviteUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteEmail(props: InviteEmailProps) {
|
||||||
|
const { inviterName, roleName, companyName, inviteUrl } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Convite para o Sistema de Chamados" preview={`${inviterName} convidou voce para acessar o Sistema de Chamados Raven`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Voce foi convidado!
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
<strong>{inviterName}</strong> convidou voce para acessar o Sistema de Chamados Raven.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
|
||||||
|
Funcao
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
{roleName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{companyName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
|
||||||
|
Empresa
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{companyName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={inviteUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aceitar convite
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Se o botao nao funcionar, copie e cole esta URL no navegador:
|
||||||
|
<br />
|
||||||
|
<a href={inviteUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
|
||||||
|
{inviteUrl}
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
InviteEmail.PreviewProps = {
|
||||||
|
inviterName: "Renan Oliveira",
|
||||||
|
roleName: "Agente",
|
||||||
|
companyName: "Paulicon",
|
||||||
|
inviteUrl: "https://raven.rever.com.br/invite/abc123def456",
|
||||||
|
} satisfies InviteEmailProps
|
||||||
150
emails/new-login-email.tsx
Normal file
150
emails/new-login-email.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type NewLoginEmailProps = {
|
||||||
|
loginAt: string
|
||||||
|
ipAddress: string
|
||||||
|
userAgent: string
|
||||||
|
location?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date)
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewLoginEmail(props: NewLoginEmailProps) {
|
||||||
|
const { loginAt, ipAddress, userAgent, location } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Novo acesso detectado" preview="Detectamos um novo acesso a sua conta">
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Novo acesso detectado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Data/Hora
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{formatDate(loginAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Endereco IP
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontFamily: "monospace" }}>
|
||||||
|
{ipAddress}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{location ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Localizacao
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{location}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Dispositivo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "13px" }}>
|
||||||
|
{userAgent}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: "0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NewLoginEmail.PreviewProps = {
|
||||||
|
loginAt: new Date().toISOString(),
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Chrome 120.0 / Windows 11",
|
||||||
|
location: "Sao Paulo, SP, Brasil",
|
||||||
|
} satisfies NewLoginEmailProps
|
||||||
81
emails/password-reset-email.tsx
Normal file
81
emails/password-reset-email.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type PasswordResetEmailProps = {
|
||||||
|
resetUrl: string
|
||||||
|
expiresIn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordResetEmail(props: PasswordResetEmailProps) {
|
||||||
|
const { resetUrl, expiresIn = "1 hora" } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Redefinicao de senha" preview="Voce solicitou a redefinicao de sua senha">
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Redefinir senha
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={resetUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Redefinir senha
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Se o botao nao funcionar, copie e cole esta URL no navegador:
|
||||||
|
<br />
|
||||||
|
<a href={resetUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
|
||||||
|
{resetUrl}
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordResetEmail.PreviewProps = {
|
||||||
|
resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456",
|
||||||
|
expiresIn: "1 hora",
|
||||||
|
} satisfies PasswordResetEmailProps
|
||||||
151
emails/sla-breached-email.tsx
Normal file
151
emails/sla-breached-email.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type SlaBreachedEmailProps = {
|
||||||
|
ticketNumber: string
|
||||||
|
ticketTitle: string
|
||||||
|
breachedAt: string
|
||||||
|
ticketUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date)
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlaBreachedEmail(props: SlaBreachedEmailProps) {
|
||||||
|
const { ticketNumber, ticketTitle, breachedAt, ticketUrl } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="SLA estourado" preview={`Chamado #${ticketNumber} estourou o SLA`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #ef4444",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚨
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
SLA estourado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fef2f2",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Chamado
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#7f1d1d", fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
#{ticketNumber}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Titulo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#7f1d1d", fontSize: "14px" }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Estourado em
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
|
||||||
|
{formatDate(breachedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
color: "#ffffff",
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: "1px solid #b91c1c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Atender agora
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Este chamado deve ser tratado com prioridade maxima.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SlaBreachedEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
breachedAt: new Date().toISOString(),
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies SlaBreachedEmailProps
|
||||||
139
emails/sla-warning-email.tsx
Normal file
139
emails/sla-warning-email.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type SlaWarningEmailProps = {
|
||||||
|
ticketNumber: string
|
||||||
|
ticketTitle: string
|
||||||
|
timeRemaining: string
|
||||||
|
ticketUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlaWarningEmail(props: SlaWarningEmailProps) {
|
||||||
|
const { ticketNumber, ticketTitle, timeRemaining, ticketUrl } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Alerta de SLA" preview={`Chamado #${ticketNumber} esta proximo de estourar o SLA`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Alerta de SLA
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado abaixo esta proximo de estourar o tempo de atendimento acordado.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fffbeb",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid #fcd34d",
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Chamado
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#78350f", fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
#{ticketNumber}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Titulo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#78350f", fontSize: "14px" }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Tempo restante
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
|
||||||
|
{timeRemaining}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Acesse o sistema para mais detalhes e acompanhe o status do chamado.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SlaWarningEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
timeRemaining: "45 minutos",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies SlaWarningEmailProps
|
||||||
82
emails/ticket-assigned-email.tsx
Normal file
82
emails/ticket-assigned-email.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type TicketAssignedEmailProps = TicketCardProps & {
|
||||||
|
ticketUrl: string
|
||||||
|
assigneeName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketAssignedEmail(props: TicketAssignedEmailProps) {
|
||||||
|
const { ticketUrl, assigneeName, ...ticketProps } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Chamado atribuido" preview={`Chamado #${ticketProps.ticketNumber} foi atribuido a ${assigneeName}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👤
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado atribuido
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado foi atribuido a <strong>{assigneeName}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketAssignedEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "HIGH",
|
||||||
|
category: "Hardware",
|
||||||
|
subcategory: "Desktop",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
assigneeName: "Weslei Magalhaes",
|
||||||
|
} satisfies TicketAssignedEmailProps
|
||||||
113
emails/ticket-comment-email.tsx
Normal file
113
emails/ticket-comment-email.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type TicketCommentEmailProps = {
|
||||||
|
ticketNumber: string
|
||||||
|
ticketTitle: string
|
||||||
|
commenterName: string
|
||||||
|
commentPreview: string
|
||||||
|
ticketUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketCommentEmail(props: TicketCommentEmailProps) {
|
||||||
|
const { ticketNumber, ticketTitle, commenterName, commentPreview, ticketUrl } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Novo comentario" preview={`${commenterName} comentou no chamado #${ticketNumber}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💬
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Novo comentario
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
<strong>{commenterName}</strong> comentou no chamado <strong>#{ticketNumber}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Chamado #{ticketNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "4px 0 0 0", fontSize: "14px", fontWeight: 600, color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Comentario
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "8px 0 0 0", fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{commentPreview}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver e responder
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Clique no botao acima para ver o comentario completo e responder.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketCommentEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
commenterName: "Weslei Magalhaes",
|
||||||
|
commentPreview: "Ola! Ja verificamos o problema e parece ser relacionado ao driver da placa de video. Vou precisar de acesso remoto para fazer a correcao...",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies TicketCommentEmailProps
|
||||||
80
emails/ticket-created-email.tsx
Normal file
80
emails/ticket-created-email.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type TicketCreatedEmailProps = TicketCardProps & {
|
||||||
|
ticketUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketCreatedEmail(props: TicketCreatedEmailProps) {
|
||||||
|
const { ticketUrl, ...ticketProps } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Novo chamado criado" preview={`Chamado #${ticketProps.ticketNumber} foi criado com sucesso`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#ecfdf5",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado criado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Acompanhar chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketCreatedEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "HIGH",
|
||||||
|
category: "Hardware",
|
||||||
|
subcategory: "Desktop",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies TicketCreatedEmailProps
|
||||||
121
emails/ticket-resolved-email.tsx
Normal file
121
emails/ticket-resolved-email.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
|
||||||
|
export type TicketResolvedEmailProps = TicketCardProps & {
|
||||||
|
ticketUrl: string
|
||||||
|
ratingUrl?: string | null
|
||||||
|
resolution?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketResolvedEmail(props: TicketResolvedEmailProps) {
|
||||||
|
const { ticketUrl, ratingUrl, resolution, ...ticketProps } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Chamado resolvido" preview={`Chamado #${ticketProps.ticketNumber} foi resolvido`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#ecfdf5",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado resolvido
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Seu chamado foi marcado como resolvido. Confira os detalhes abaixo.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} status="RESOLVED" />
|
||||||
|
|
||||||
|
{resolution ? (
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
padding: "16px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ margin: "0 0 8px 0", fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Resolucao
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: 0, fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{resolution}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver detalhes
|
||||||
|
</Button>
|
||||||
|
{ratingUrl ? (
|
||||||
|
<Button
|
||||||
|
href={ratingUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
color: "#f8fafc",
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
marginLeft: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avaliar atendimento
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketResolvedEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
status: "RESOLVED",
|
||||||
|
priority: "HIGH",
|
||||||
|
category: "Hardware",
|
||||||
|
subcategory: "Desktop",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
ratingUrl: "https://raven.rever.com.br/rate/abc123",
|
||||||
|
resolution: "Problema resolvido apos atualizacao do driver da placa de video e reinicializacao do sistema.",
|
||||||
|
} satisfies TicketResolvedEmailProps
|
||||||
85
emails/ticket-status-email.tsx
Normal file
85
emails/ticket-status-email.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
||||||
|
|
||||||
|
import { RavenEmailLayout } from "./_components/layout"
|
||||||
|
import { TicketCard, type TicketCardProps } from "./_components/ticket-card"
|
||||||
|
import { EMAIL_COLORS } from "./_components/tokens"
|
||||||
|
import { formatStatus } from "./_components/utils"
|
||||||
|
|
||||||
|
export type TicketStatusEmailProps = TicketCardProps & {
|
||||||
|
ticketUrl: string
|
||||||
|
previousStatus: string
|
||||||
|
newStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketStatusEmail(props: TicketStatusEmailProps) {
|
||||||
|
const { ticketUrl, previousStatus, newStatus, ...ticketProps } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RavenEmailLayout title="Status atualizado" preview={`Chamado #${ticketProps.ticketNumber} mudou de ${formatStatus(previousStatus)} para ${formatStatus(newStatus)}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Status atualizado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O status do seu chamado foi alterado de <strong>{formatStatus(previousStatus)}</strong> para <strong>{formatStatus(newStatus)}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} status={newStatus} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketStatusEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "HIGH",
|
||||||
|
category: "Hardware",
|
||||||
|
subcategory: "Desktop",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
previousStatus: "PENDING",
|
||||||
|
newStatus: "AWAITING_ATTENDANCE",
|
||||||
|
} satisfies TicketStatusEmailProps
|
||||||
|
|
@ -15,6 +15,7 @@ const eslintConfig = [
|
||||||
"referência/**",
|
"referência/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"convex/_generated/**",
|
"convex/_generated/**",
|
||||||
|
"apps/desktop/src/convex/_generated/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
113
forgejo/setup-runner.sh
Normal file
113
forgejo/setup-runner.sh
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script para configurar o Forgejo Runner
|
||||||
|
# Execute na VPS apos o Forgejo estar rodando
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FORGEJO_URL="${FORGEJO_URL:-https://git.esdrasrenan.com.br}"
|
||||||
|
RUNNER_NAME="${RUNNER_NAME:-vps-runner}"
|
||||||
|
RUNNER_DIR="/srv/forgejo-runner"
|
||||||
|
CONFIG_FILE="$RUNNER_DIR/config.yml"
|
||||||
|
|
||||||
|
echo "=== Configuracao do Forgejo Runner ==="
|
||||||
|
echo ""
|
||||||
|
echo "1. Acesse o Forgejo: $FORGEJO_URL"
|
||||||
|
echo "2. Va em: Site Administration > Actions > Runners"
|
||||||
|
echo "3. Clique em 'Create new Runner'"
|
||||||
|
echo "4. Copie o token de registro"
|
||||||
|
echo ""
|
||||||
|
read -p "Cole o token de registro aqui: " REGISTRATION_TOKEN
|
||||||
|
|
||||||
|
if [ -z "$REGISTRATION_TOKEN" ]; then
|
||||||
|
echo "ERRO: Token nao pode ser vazio"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Criar diretorio do runner
|
||||||
|
mkdir -p "$RUNNER_DIR"
|
||||||
|
cd "$RUNNER_DIR"
|
||||||
|
|
||||||
|
# Baixar o runner se nao existir
|
||||||
|
if [ ! -f "./forgejo-runner" ]; then
|
||||||
|
echo "Baixando Forgejo Runner..."
|
||||||
|
RUNNER_VERSION="6.2.2"
|
||||||
|
curl -L -o forgejo-runner "https://code.forgejo.org/forgejo/runner/releases/download/v${RUNNER_VERSION}/forgejo-runner-${RUNNER_VERSION}-linux-amd64"
|
||||||
|
chmod +x forgejo-runner
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Registrar o runner
|
||||||
|
echo "Registrando runner..."
|
||||||
|
./forgejo-runner register \
|
||||||
|
--instance "$FORGEJO_URL" \
|
||||||
|
--token "$REGISTRATION_TOKEN" \
|
||||||
|
--name "$RUNNER_NAME" \
|
||||||
|
--labels "ubuntu-latest:docker://node:20-bookworm,self-hosted:host,linux:host,vps:host" \
|
||||||
|
--no-interactive
|
||||||
|
|
||||||
|
# Criar config.yml customizado
|
||||||
|
cat > "$CONFIG_FILE" << 'EOF'
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 2
|
||||||
|
timeout: 3h
|
||||||
|
insecure: false
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://node:20-bookworm"
|
||||||
|
- "self-hosted:host"
|
||||||
|
- "linux:host"
|
||||||
|
- "vps:host"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: /tmp/forgejo-runner-cache
|
||||||
|
host: ""
|
||||||
|
port: 0
|
||||||
|
external_server: ""
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: "host"
|
||||||
|
privileged: false
|
||||||
|
options: ""
|
||||||
|
workdir_parent: /tmp/forgejo-runner-workdir
|
||||||
|
valid_volumes:
|
||||||
|
- /var/run/docker.sock
|
||||||
|
- /home/runner/apps
|
||||||
|
- /srv/apps
|
||||||
|
- /tmp
|
||||||
|
docker_host: ""
|
||||||
|
force_pull: false
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: /tmp/forgejo-runner-workdir
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Runner registrado com sucesso! ==="
|
||||||
|
echo ""
|
||||||
|
echo "Para iniciar o runner como servico systemd, execute:"
|
||||||
|
echo ""
|
||||||
|
echo "sudo tee /etc/systemd/system/forgejo-runner.service << 'SYSTEMD'
|
||||||
|
[Unit]
|
||||||
|
Description=Forgejo Runner
|
||||||
|
After=docker.service network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=runner
|
||||||
|
WorkingDirectory=$RUNNER_DIR
|
||||||
|
ExecStart=$RUNNER_DIR/forgejo-runner daemon --config $CONFIG_FILE
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SYSTEMD"
|
||||||
|
echo ""
|
||||||
|
echo "sudo systemctl daemon-reload"
|
||||||
|
echo "sudo systemctl enable forgejo-runner"
|
||||||
|
echo "sudo systemctl start forgejo-runner"
|
||||||
89
forgejo/stack.yml
Normal file
89
forgejo/stack.yml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Forgejo para CI/CD self-hosted
|
||||||
|
# Substitui o GitHub Actions sem perder a experiencia visual
|
||||||
|
# NOTA: O runner roda como servico systemd, nao como container no Swarm
|
||||||
|
|
||||||
|
services:
|
||||||
|
forgejo:
|
||||||
|
image: codeberg.org/forgejo/forgejo:11
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
# Configuracoes do Forgejo
|
||||||
|
- FORGEJO__database__DB_TYPE=sqlite3
|
||||||
|
- FORGEJO__database__PATH=/data/gitea/forgejo.db
|
||||||
|
- FORGEJO__server__DOMAIN=git.esdrasrenan.com.br
|
||||||
|
- FORGEJO__server__ROOT_URL=https://git.esdrasrenan.com.br/
|
||||||
|
- FORGEJO__server__SSH_DOMAIN=git.esdrasrenan.com.br
|
||||||
|
- FORGEJO__server__SSH_PORT=2222
|
||||||
|
- FORGEJO__server__HTTP_PORT=3000
|
||||||
|
- FORGEJO__server__OFFLINE_MODE=false
|
||||||
|
# Actions habilitado
|
||||||
|
- FORGEJO__actions__ENABLED=true
|
||||||
|
- FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
|
||||||
|
# Seguranca - INSTALL_LOCK=true apos instalacao inicial
|
||||||
|
- FORGEJO__security__INSTALL_LOCK=true
|
||||||
|
- FORGEJO__service__DISABLE_REGISTRATION=true
|
||||||
|
# Queue - usar channel em vez de leveldb para evitar problemas de lock
|
||||||
|
- FORGEJO__queue__TYPE=channel
|
||||||
|
- FORGEJO__queue__DATADIR=queues/
|
||||||
|
# Logs
|
||||||
|
- FORGEJO__log__MODE=console
|
||||||
|
- FORGEJO__log__LEVEL=Info
|
||||||
|
volumes:
|
||||||
|
- forgejo_data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
networks:
|
||||||
|
- traefik_public
|
||||||
|
- forgejo_internal
|
||||||
|
ports:
|
||||||
|
# SSH para git clone via SSH (exposto diretamente)
|
||||||
|
- "2222:2222"
|
||||||
|
deploy:
|
||||||
|
mode: replicated
|
||||||
|
replicas: 1
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
order: start-first
|
||||||
|
failure_action: rollback
|
||||||
|
delay: 10s
|
||||||
|
monitor: 30s
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "1G"
|
||||||
|
reservations:
|
||||||
|
memory: "256M"
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
window: 120s
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik_public
|
||||||
|
# Web UI
|
||||||
|
- traefik.http.routers.forgejo.rule=Host(`git.esdrasrenan.com.br`)
|
||||||
|
- traefik.http.routers.forgejo.entrypoints=websecure
|
||||||
|
- traefik.http.routers.forgejo.tls=true
|
||||||
|
- traefik.http.routers.forgejo.tls.certresolver=le
|
||||||
|
- traefik.http.services.forgejo.loadbalancer.server.port=3000
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
forgejo_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_public:
|
||||||
|
external: true
|
||||||
|
forgejo_internal:
|
||||||
|
driver: overlay
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
|
|
||||||
252
scripts/setup-dev.sh
Normal file
252
scripts/setup-dev.sh
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de setup para ambiente de desenvolvimento
|
||||||
|
# Uso: ./scripts/setup-dev.sh [--ssh]
|
||||||
|
#
|
||||||
|
# Opcoes:
|
||||||
|
# --ssh Configurar remotes usando SSH (para repositorio privado)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Verificar se deve usar SSH
|
||||||
|
USE_SSH=false
|
||||||
|
if [ "$1" = "--ssh" ]; then
|
||||||
|
USE_SSH=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Setup do Ambiente de Desenvolvimento ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Cores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Funcao para printar status
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[AVISO]${NC} $1"; }
|
||||||
|
err() { echo -e "${RED}[ERRO]${NC} $1"; }
|
||||||
|
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
|
||||||
|
|
||||||
|
# 1. Verificar pre-requisitos
|
||||||
|
echo "1. Verificando pre-requisitos..."
|
||||||
|
|
||||||
|
# Verificar Bun
|
||||||
|
if command -v bun &> /dev/null; then
|
||||||
|
BUN_VERSION=$(bun --version)
|
||||||
|
ok "Bun instalado: v$BUN_VERSION"
|
||||||
|
else
|
||||||
|
err "Bun nao encontrado!"
|
||||||
|
echo " Instale com: curl -fsSL https://bun.sh/install | bash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar Docker
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
ok "Docker instalado"
|
||||||
|
else
|
||||||
|
warn "Docker nao encontrado. Voce precisara configurar o PostgreSQL manualmente."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar Git
|
||||||
|
if command -v git &> /dev/null; then
|
||||||
|
ok "Git instalado"
|
||||||
|
else
|
||||||
|
err "Git nao encontrado!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar SSH key (se usando SSH)
|
||||||
|
if [ "$USE_SSH" = true ]; then
|
||||||
|
echo ""
|
||||||
|
echo "1.1. Verificando chave SSH..."
|
||||||
|
if [ -f "$HOME/.ssh/id_ed25519.pub" ] || [ -f "$HOME/.ssh/id_rsa.pub" ]; then
|
||||||
|
ok "Chave SSH encontrada"
|
||||||
|
echo " Certifique-se de que a chave esta adicionada no GitHub e Forgejo"
|
||||||
|
else
|
||||||
|
warn "Chave SSH nao encontrada!"
|
||||||
|
echo ""
|
||||||
|
echo " Para criar uma chave SSH:"
|
||||||
|
echo " ssh-keygen -t ed25519 -C \"seu-email@exemplo.com\""
|
||||||
|
echo ""
|
||||||
|
echo " Depois adicione a chave publica em:"
|
||||||
|
echo " - GitHub: Settings > SSH and GPG keys > New SSH key"
|
||||||
|
echo " - Forgejo: Settings > SSH / GPG Keys > Add Key"
|
||||||
|
echo ""
|
||||||
|
read -p " Deseja continuar mesmo assim? (s/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Configurar remotes do Git
|
||||||
|
echo "2. Configurando remotes do Git..."
|
||||||
|
|
||||||
|
# Verificar se estamos em um repositorio git
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
err "Este diretorio nao e um repositorio Git!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# URLs dos remotes
|
||||||
|
if [ "$USE_SSH" = true ]; then
|
||||||
|
ORIGIN_URL="git@github.com:esdrasrenan/sistema-de-chamados.git"
|
||||||
|
FORGEJO_URL="ssh://git@git.esdrasrenan.com.br:2222/esdras/sistema-de-chamados.git"
|
||||||
|
info "Usando SSH para os remotes (repositorio privado)"
|
||||||
|
else
|
||||||
|
ORIGIN_URL="https://github.com/esdrasrenan/sistema-de-chamados.git"
|
||||||
|
FORGEJO_URL="https://git.esdrasrenan.com.br/esdras/sistema-de-chamados.git"
|
||||||
|
info "Usando HTTPS para os remotes (repositorio publico)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar/atualizar origin
|
||||||
|
CURRENT_ORIGIN=$(git remote get-url origin 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT_ORIGIN" != "$ORIGIN_URL" ]; then
|
||||||
|
if [ -n "$CURRENT_ORIGIN" ]; then
|
||||||
|
git remote set-url origin "$ORIGIN_URL"
|
||||||
|
ok "Remote 'origin' atualizado para $ORIGIN_URL"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Remote 'origin' ja configurado corretamente"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar/adicionar remote forgejo
|
||||||
|
if git remote get-url forgejo &> /dev/null; then
|
||||||
|
CURRENT_FORGEJO=$(git remote get-url forgejo)
|
||||||
|
if [ "$CURRENT_FORGEJO" != "$FORGEJO_URL" ]; then
|
||||||
|
git remote set-url forgejo "$FORGEJO_URL"
|
||||||
|
ok "Remote 'forgejo' atualizado para $FORGEJO_URL"
|
||||||
|
else
|
||||||
|
ok "Remote 'forgejo' ja configurado corretamente"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git remote add forgejo "$FORGEJO_URL"
|
||||||
|
ok "Remote 'forgejo' adicionado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mostrar remotes
|
||||||
|
echo " Remotes configurados:"
|
||||||
|
git remote -v | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Instalar dependencias
|
||||||
|
echo "3. Instalando dependencias..."
|
||||||
|
bun install
|
||||||
|
ok "Dependencias instaladas"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Configurar arquivo .env
|
||||||
|
echo "4. Configurando arquivo .env..."
|
||||||
|
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
warn "Arquivo .env ja existe. Pulando..."
|
||||||
|
else
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
ok "Arquivo .env criado a partir do .env.example"
|
||||||
|
warn "IMPORTANTE: Edite o arquivo .env com suas configuracoes!"
|
||||||
|
else
|
||||||
|
# Criar .env basico
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||||
|
BETTER_AUTH_SECRET=dev-secret-change-in-production
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210
|
||||||
|
EOF
|
||||||
|
ok "Arquivo .env criado com valores padrao para desenvolvimento"
|
||||||
|
warn "IMPORTANTE: Ajuste as configuracoes conforme necessario!"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 5. Configurar PostgreSQL via Docker
|
||||||
|
echo "5. Configurando PostgreSQL..."
|
||||||
|
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q '^postgres-dev$'; then
|
||||||
|
# Container existe, verificar se esta rodando
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q '^postgres-dev$'; then
|
||||||
|
ok "PostgreSQL ja esta rodando"
|
||||||
|
else
|
||||||
|
docker start postgres-dev
|
||||||
|
ok "PostgreSQL iniciado"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Criar container
|
||||||
|
docker run -d \
|
||||||
|
--name postgres-dev \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_PASSWORD=dev \
|
||||||
|
-e POSTGRES_DB=sistema_chamados \
|
||||||
|
postgres:16
|
||||||
|
ok "PostgreSQL criado e iniciado"
|
||||||
|
echo " Aguardando PostgreSQL inicializar..."
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Docker nao disponivel. Configure o PostgreSQL manualmente."
|
||||||
|
echo " DATABASE_URL deve apontar para seu servidor PostgreSQL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 6. Gerar cliente Prisma
|
||||||
|
echo "6. Gerando cliente Prisma..."
|
||||||
|
bun run prisma:generate
|
||||||
|
ok "Cliente Prisma gerado"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 7. Inicializar banco de dados
|
||||||
|
echo "7. Inicializando banco de dados..."
|
||||||
|
|
||||||
|
# Verificar se o banco esta acessivel
|
||||||
|
if bunx prisma db push --skip-generate 2>/dev/null; then
|
||||||
|
ok "Schema do banco atualizado"
|
||||||
|
|
||||||
|
# Seed inicial
|
||||||
|
echo " Populando dados iniciais..."
|
||||||
|
if bun run auth:seed 2>/dev/null; then
|
||||||
|
ok "Dados iniciais criados"
|
||||||
|
else
|
||||||
|
warn "Seed falhou ou ja foi executado anteriormente"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Nao foi possivel conectar ao banco de dados"
|
||||||
|
echo " Verifique se o PostgreSQL esta rodando e as credenciais no .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 8. Configurar alias do Git (opcional)
|
||||||
|
echo "8. Configurando alias do Git..."
|
||||||
|
|
||||||
|
if git config --get alias.push-all &> /dev/null; then
|
||||||
|
ok "Alias 'push-all' ja configurado"
|
||||||
|
else
|
||||||
|
git config alias.push-all '!git push origin main && git push forgejo main'
|
||||||
|
ok "Alias 'push-all' criado (use: git push-all)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Setup Concluido! ==="
|
||||||
|
echo ""
|
||||||
|
echo "Proximos passos:"
|
||||||
|
echo " 1. Verifique/edite o arquivo .env"
|
||||||
|
echo " 2. Execute: bun run dev:bun"
|
||||||
|
echo " 3. Acesse: http://localhost:3000"
|
||||||
|
echo " 4. Login: admin@sistema.dev / admin123"
|
||||||
|
echo ""
|
||||||
|
echo "Para fazer deploy:"
|
||||||
|
echo " git push origin main && git push forgejo main"
|
||||||
|
echo " ou: git push-all"
|
||||||
|
echo ""
|
||||||
188
scripts/test-all-emails.tsx
Normal file
188
scripts/test-all-emails.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
import { render } from "@react-email/render"
|
||||||
|
|
||||||
|
import { sendSmtpMail } from "@/server/email-smtp"
|
||||||
|
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
||||||
|
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" })
|
||||||
|
dotenv.config({ path: ".env" })
|
||||||
|
|
||||||
|
function getSmtpConfig() {
|
||||||
|
const host = process.env.SMTP_HOST
|
||||||
|
const port = process.env.SMTP_PORT
|
||||||
|
const username = process.env.SMTP_USER
|
||||||
|
const password = process.env.SMTP_PASS
|
||||||
|
const fromEmail = process.env.SMTP_FROM_EMAIL
|
||||||
|
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
|
||||||
|
|
||||||
|
if (!host || !port || !username || !password || !fromEmail) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(port),
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
|
tls: process.env.SMTP_SECURE === "true",
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailScenario = {
|
||||||
|
name: string
|
||||||
|
subject: string
|
||||||
|
render: () => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tickets.esdrasrenan.com.br"
|
||||||
|
|
||||||
|
const scenarios: EmailScenario[] = [
|
||||||
|
{
|
||||||
|
name: "Ticket Criado",
|
||||||
|
subject: "[TESTE] Novo chamado #41025 aberto",
|
||||||
|
render: async () => {
|
||||||
|
const props: SimpleNotificationEmailProps = {
|
||||||
|
title: "Novo chamado #41025 aberto",
|
||||||
|
message: "Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: Computador reiniciando sozinho\nPrioridade: Alta\nStatus: Pendente",
|
||||||
|
ctaLabel: "Ver chamado",
|
||||||
|
ctaUrl: `${baseUrl}/portal/tickets/test123`,
|
||||||
|
}
|
||||||
|
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ticket Resolvido",
|
||||||
|
subject: "[TESTE] Chamado #41025 foi encerrado",
|
||||||
|
render: async () => {
|
||||||
|
const props: SimpleNotificationEmailProps = {
|
||||||
|
title: "Chamado #41025 encerrado",
|
||||||
|
message: "O chamado 'Computador reiniciando sozinho' foi marcado como concluído.\n\nCaso necessário, você pode responder pelo portal para reabrir dentro do prazo.",
|
||||||
|
ctaLabel: "Ver detalhes",
|
||||||
|
ctaUrl: `${baseUrl}/portal/tickets/test123`,
|
||||||
|
}
|
||||||
|
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Novo Comentário",
|
||||||
|
subject: "[TESTE] Atualização no chamado #41025",
|
||||||
|
render: async () => {
|
||||||
|
const props: SimpleNotificationEmailProps = {
|
||||||
|
title: "Nova atualização no seu chamado #41025",
|
||||||
|
message: "Um novo comentário foi adicionado ao chamado 'Computador reiniciando sozinho'.\n\nClique abaixo para visualizar e responder pelo portal.",
|
||||||
|
ctaLabel: "Abrir e responder",
|
||||||
|
ctaUrl: `${baseUrl}/portal/tickets/test123`,
|
||||||
|
}
|
||||||
|
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Automação - Mudança de Prioridade",
|
||||||
|
subject: "[TESTE] Prioridade alterada no chamado #41025",
|
||||||
|
render: async () => {
|
||||||
|
const props: AutomationEmailProps = {
|
||||||
|
title: "Prioridade alterada para Urgente",
|
||||||
|
message: "A prioridade do seu chamado foi alterada automaticamente pelo sistema.\n\nIsso pode ter ocorrido devido a regras de SLA ou categorização automática.",
|
||||||
|
ticket: {
|
||||||
|
reference: 41025,
|
||||||
|
subject: "Computador reiniciando sozinho",
|
||||||
|
companyName: "Paulicon Contabil",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "URGENT",
|
||||||
|
requesterName: "Renan",
|
||||||
|
assigneeName: "Administrador",
|
||||||
|
},
|
||||||
|
ctaLabel: "Ver chamado",
|
||||||
|
ctaUrl: `${baseUrl}/portal/tickets/test123`,
|
||||||
|
}
|
||||||
|
return render(<AutomationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Automação - Atribuição de Agente",
|
||||||
|
subject: "[TESTE] Agente atribuído ao chamado #41025",
|
||||||
|
render: async () => {
|
||||||
|
const props: AutomationEmailProps = {
|
||||||
|
title: "Agente atribuído ao seu chamado",
|
||||||
|
message: "O agente Administrador foi automaticamente atribuído ao seu chamado e entrará em contato em breve.",
|
||||||
|
ticket: {
|
||||||
|
reference: 41025,
|
||||||
|
subject: "Computador reiniciando sozinho",
|
||||||
|
companyName: "Paulicon Contabil",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "HIGH",
|
||||||
|
requesterName: "Renan",
|
||||||
|
assigneeName: "Administrador",
|
||||||
|
},
|
||||||
|
ctaLabel: "Ver chamado",
|
||||||
|
ctaUrl: `${baseUrl}/portal/tickets/test123`,
|
||||||
|
}
|
||||||
|
return render(<AutomationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Redefinição de Senha",
|
||||||
|
subject: "[TESTE] Redefinição de senha - Raven",
|
||||||
|
render: async () => {
|
||||||
|
const props: SimpleNotificationEmailProps = {
|
||||||
|
title: "Redefinição de Senha",
|
||||||
|
message: "Recebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail.\n\nEste link expira em 1 hora.",
|
||||||
|
ctaLabel: "Redefinir Senha",
|
||||||
|
ctaUrl: `${baseUrl}/redefinir-senha?token=abc123def456`,
|
||||||
|
}
|
||||||
|
return render(<SimpleNotificationEmail {...props} />, { pretty: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const targetEmail = process.argv[2] ?? "renan.pac@paulicon.com.br"
|
||||||
|
|
||||||
|
const smtp = getSmtpConfig()
|
||||||
|
if (!smtp) {
|
||||||
|
console.error("SMTP não configurado. Defina as variáveis SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=".repeat(60))
|
||||||
|
console.log("Teste de E-mails - Sistema de Chamados Raven")
|
||||||
|
console.log("=".repeat(60))
|
||||||
|
console.log(`\nDestinatario: ${targetEmail}`)
|
||||||
|
console.log(`SMTP: ${smtp.host}:${smtp.port}`)
|
||||||
|
console.log(`De: ${smtp.from}`)
|
||||||
|
console.log(`\nEnviando ${scenarios.length} e-mails de teste...\n`)
|
||||||
|
|
||||||
|
let success = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
try {
|
||||||
|
process.stdout.write(` ${scenario.name}... `)
|
||||||
|
const html = await scenario.render()
|
||||||
|
await sendSmtpMail(smtp, targetEmail, scenario.subject, html)
|
||||||
|
console.log("OK")
|
||||||
|
success++
|
||||||
|
// Pequeno delay entre envios para evitar rate limit
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`ERRO: ${error instanceof Error ? error.message : error}`)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(60))
|
||||||
|
console.log(`Resultado: ${success} enviados, ${failed} falharam`)
|
||||||
|
console.log("=".repeat(60))
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Erro fatal:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
209
scripts/test-email.ts
Normal file
209
scripts/test-email.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* Script para testar envio de e-mail
|
||||||
|
* Uso: bun scripts/test-email.ts [destinatario]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendSmtpMail } from "../src/server/email-smtp"
|
||||||
|
import { renderTemplate } from "../src/server/email/email-templates"
|
||||||
|
|
||||||
|
const DESTINATARIO = process.argv[2] || "renan.pac@paulicon.com.br"
|
||||||
|
|
||||||
|
// Credenciais do SMTP (usando as da documentacao)
|
||||||
|
const smtpConfig = {
|
||||||
|
host: "smtp.c.inova.com.br",
|
||||||
|
port: 587,
|
||||||
|
username: "envio@rever.com.br",
|
||||||
|
password: "CAAJQm6ZT6AUdhXRTDYu",
|
||||||
|
from: '"Sistema de Chamados" <envio@rever.com.br>',
|
||||||
|
starttls: true,
|
||||||
|
tls: false,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testEmail() {
|
||||||
|
console.log("=".repeat(50))
|
||||||
|
console.log("TESTE DE ENVIO DE E-MAIL")
|
||||||
|
console.log("=".repeat(50))
|
||||||
|
console.log(`Destinatario: ${DESTINATARIO}`)
|
||||||
|
console.log(`SMTP: ${smtpConfig.host}:${smtpConfig.port}`)
|
||||||
|
console.log("")
|
||||||
|
|
||||||
|
// 1. Teste basico
|
||||||
|
console.log("[1/10] Enviando e-mail de teste basico...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("test", {
|
||||||
|
title: "Teste do Sistema de E-mail",
|
||||||
|
message: "Este e-mail confirma que o sistema de notificacoes esta funcionando corretamente.",
|
||||||
|
timestamp: new Date().toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Teste - Sistema de Chamados Raven", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Teste de abertura de chamado
|
||||||
|
console.log("\n[2/10] Enviando notificacao de abertura de chamado...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("ticket_created", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "HIGH",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Aberto - Problema no sistema de vendas", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Teste de resolucao de chamado
|
||||||
|
console.log("\n[3/10] Enviando notificacao de resolucao...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("ticket_resolved", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
assigneeName: "Joao Silva",
|
||||||
|
resolutionSummary: "O problema foi identificado como uma configuracao incorreta no modulo de pagamentos. A configuracao foi corrigida e o sistema esta funcionando normalmente.",
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
rateUrl: "https://tickets.esdrasrenan.com.br/rate/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Resolvido - Problema no sistema de vendas", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Teste de comentario
|
||||||
|
console.log("\n[4/10] Enviando notificacao de comentario...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("ticket_comment", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
authorName: "Joao Silva",
|
||||||
|
commentBody: "Estou analisando o problema e em breve envio uma atualizacao. Por favor, verifique se o erro persiste apos limpar o cache do navegador.",
|
||||||
|
commentedAt: new Date().toISOString(),
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Nova atualizacao no Chamado #12345", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Teste de atribuicao de chamado
|
||||||
|
console.log("\n[5/10] Enviando notificacao de atribuicao...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("ticket_assigned", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "HIGH",
|
||||||
|
requesterName: "Maria Santos",
|
||||||
|
assigneeName: "Joao Silva",
|
||||||
|
isForRequester: false,
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Chamado #12345 Atribuido", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Teste de mudanca de status
|
||||||
|
console.log("\n[6/10] Enviando notificacao de mudanca de status...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("ticket_status", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
oldStatus: "PENDING",
|
||||||
|
newStatus: "AWAITING_ATTENDANCE",
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Status do Chamado #12345 Alterado", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Teste de reset de senha
|
||||||
|
console.log("\n[7/10] Enviando notificacao de reset de senha...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("password_reset", {
|
||||||
|
resetUrl: "https://tickets.esdrasrenan.com.br/reset-password?token=abc123",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Redefinicao de Senha - Raven", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Teste de convite
|
||||||
|
console.log("\n[8/10] Enviando notificacao de convite...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("invite", {
|
||||||
|
inviterName: "Admin Sistema",
|
||||||
|
roleName: "Agente",
|
||||||
|
companyName: "Empresa Teste",
|
||||||
|
inviteUrl: "https://tickets.esdrasrenan.com.br/invite?token=xyz789",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Voce foi convidado - Raven", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Teste de novo login
|
||||||
|
console.log("\n[9/10] Enviando notificacao de novo login...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("new_login", {
|
||||||
|
loginAt: new Date().toISOString(),
|
||||||
|
userAgent: "Chrome 120 no Windows 11",
|
||||||
|
ipAddress: "189.45.123.78",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "Novo Acesso Detectado - Raven", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Teste de SLA em risco
|
||||||
|
console.log("\n[10/10] Enviando notificacao de SLA em risco...")
|
||||||
|
try {
|
||||||
|
const html = renderTemplate("sla_warning", {
|
||||||
|
reference: 12345,
|
||||||
|
subject: "Problema no sistema de vendas",
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
priority: "HIGH",
|
||||||
|
requesterName: "Maria Santos",
|
||||||
|
assigneeName: "Joao Silva",
|
||||||
|
timeRemaining: "2 horas",
|
||||||
|
dueAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
viewUrl: "https://tickets.esdrasrenan.com.br/tickets/12345",
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendSmtpMail(smtpConfig, DESTINATARIO, "ALERTA: SLA em Risco - Chamado #12345", html)
|
||||||
|
console.log(" SUCESSO!")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ERRO:", (error as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(50))
|
||||||
|
console.log("TESTE CONCLUIDO - 10 TIPOS DE NOTIFICACAO")
|
||||||
|
console.log("=".repeat(50))
|
||||||
|
console.log(`Verifique a caixa de entrada de: ${DESTINATARIO}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
testEmail().catch(console.error)
|
||||||
|
|
@ -1,63 +1,24 @@
|
||||||
import path from "node:path"
|
import pg from "pg"
|
||||||
|
|
||||||
// NOTE: This helper imports the generated Prisma client from TypeScript files.
|
// 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).
|
// Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun).
|
||||||
import { PrismaClient } from "../../src/generated/prisma/client.ts"
|
import { PrismaClient } from "../../src/generated/prisma/client.ts"
|
||||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"
|
import { PrismaPg } from "@prisma/adapter-pg"
|
||||||
|
|
||||||
const PROJECT_ROOT = process.cwd()
|
const { Pool } = pg
|
||||||
const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma")
|
|
||||||
|
|
||||||
function resolveFileUrl(url) {
|
|
||||||
if (!url.startsWith("file:")) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = url.slice("file:".length)
|
|
||||||
|
|
||||||
if (filePath.startsWith("//")) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.isAbsolute(filePath)) {
|
|
||||||
return `file:${path.normalize(filePath)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = path.normalize(filePath)
|
|
||||||
const prismaPrefix = `prisma${path.sep}`
|
|
||||||
const relativeToPrisma = normalized.startsWith(prismaPrefix)
|
|
||||||
? normalized.slice(prismaPrefix.length)
|
|
||||||
: normalized
|
|
||||||
|
|
||||||
const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma)
|
|
||||||
|
|
||||||
if (!absolutePath.startsWith(PROJECT_ROOT)) {
|
|
||||||
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `file:${absolutePath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDatasourceUrl(envUrl) {
|
|
||||||
const trimmed = envUrl?.trim()
|
|
||||||
if (trimmed) {
|
|
||||||
return resolveFileUrl(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
return "file:/app/data/db.sqlite"
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveFileUrl("file:./db.dev.sqlite")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPrismaClient() {
|
export function createPrismaClient() {
|
||||||
const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL)
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
process.env.DATABASE_URL = resolvedDatabaseUrl
|
|
||||||
|
|
||||||
const adapter = new PrismaBetterSqlite3({
|
if (!databaseUrl) {
|
||||||
url: resolvedDatabaseUrl,
|
throw new Error("DATABASE_URL environment variable is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: databaseUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const adapter = new PrismaPg(pool)
|
||||||
|
|
||||||
return new PrismaClient({ adapter })
|
return new PrismaClient({ adapter })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/app/api/admin/fix-chat-sessions/route.ts
Normal file
30
src/app/api/admin/fix-chat-sessions/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { assertAdminSession } from "@/lib/auth-server"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await assertAdminSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
if (!convexUrl) {
|
||||||
|
return NextResponse.json({ error: "CONVEX_URL não configurada" }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const convex = new ConvexHttpClient(convexUrl)
|
||||||
|
const result = await convex.mutation(api.liveChat.fixLegacySessions, {})
|
||||||
|
return NextResponse.json({ success: true, result })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[fix-chat-sessions] Erro:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "Falha ao corrigir sessões" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
||||||
import { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
import { notifyUserInvite } from "@/server/notification/notification-service"
|
||||||
|
|
||||||
const DEFAULT_EXPIRATION_DAYS = 7
|
const DEFAULT_EXPIRATION_DAYS = 7
|
||||||
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
|
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
|
||||||
|
|
@ -27,6 +28,17 @@ function normalizeRole(input: string | null | undefined): RoleOption {
|
||||||
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "Administrador",
|
||||||
|
manager: "Gestor",
|
||||||
|
agent: "Agente",
|
||||||
|
collaborator: "Colaborador",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleName(role: string): string {
|
||||||
|
return ROLE_LABELS[role.toLowerCase()] ?? role
|
||||||
|
}
|
||||||
|
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
return randomBytes(32).toString("hex")
|
return randomBytes(32).toString("hex")
|
||||||
}
|
}
|
||||||
|
|
@ -213,5 +225,24 @@ export async function POST(request: Request) {
|
||||||
const normalized = buildInvitePayload(inviteWithEvents, now)
|
const normalized = buildInvitePayload(inviteWithEvents, now)
|
||||||
await syncInviteWithConvex(normalized)
|
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 })
|
return NextResponse.json({ invite: normalized })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const createdDomainUser = await tx.user.upsert({
|
const createdDomainUser = await tx.user.upsert({
|
||||||
where: { email },
|
where: { id: createdAuthUser.id },
|
||||||
update: {
|
update: {
|
||||||
name,
|
name,
|
||||||
role: userRole,
|
role: userRole,
|
||||||
|
|
@ -213,6 +213,7 @@ export async function POST(request: Request) {
|
||||||
managerId: managerRecord?.id ?? null,
|
managerId: managerRecord?.id ?? null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
|
id: createdAuthUser.id,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
role: userRole,
|
role: userRole,
|
||||||
|
|
|
||||||
101
src/app/api/auth/forgot-password/route.ts
Normal file
101
src/app/api/auth/forgot-password/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
import { render } from "@react-email/render"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendSmtpMail } from "@/server/email-smtp"
|
||||||
|
import SimpleNotificationEmail from "../../../../../emails/simple-notification-email"
|
||||||
|
|
||||||
|
function getSmtpConfig() {
|
||||||
|
const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS
|
||||||
|
const port = process.env.SMTP_PORT
|
||||||
|
const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME
|
||||||
|
const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD
|
||||||
|
const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL
|
||||||
|
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
|
||||||
|
|
||||||
|
if (!host || !port || !username || !password || !fromEmail) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(port),
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
|
tls: process.env.SMTP_SECURE === "true",
|
||||||
|
starttls: process.env.SMTP_SECURE !== "true",
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { email } = body
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança)
|
||||||
|
const user = await prisma.authUser.findFirst({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sempre retorna sucesso para não revelar se o e-mail existe
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gera um token seguro
|
||||||
|
const token = crypto.randomBytes(32).toString("hex")
|
||||||
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora
|
||||||
|
|
||||||
|
// Remove tokens anteriores do mesmo usuário
|
||||||
|
await prisma.authVerification.deleteMany({
|
||||||
|
where: {
|
||||||
|
identifier: `password-reset:${user.id}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Salva o novo token
|
||||||
|
await prisma.authVerification.create({
|
||||||
|
data: {
|
||||||
|
identifier: `password-reset:${user.id}`,
|
||||||
|
value: token,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Envia o e-mail
|
||||||
|
const smtp = getSmtpConfig()
|
||||||
|
if (!smtp) {
|
||||||
|
console.error("[FORGOT_PASSWORD] SMTP não configurado")
|
||||||
|
return NextResponse.json({ success: true }) // Não revela erro de configuração
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
|
||||||
|
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
SimpleNotificationEmail({
|
||||||
|
title: "Redefinição de Senha",
|
||||||
|
message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`,
|
||||||
|
ctaLabel: "Redefinir Senha",
|
||||||
|
ctaUrl: resetUrl,
|
||||||
|
}),
|
||||||
|
{ pretty: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[FORGOT_PASSWORD] Erro:", error)
|
||||||
|
return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/app/api/auth/reset-password/route.ts
Normal file
97
src/app/api/auth/reset-password/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { hashPassword } from "better-auth/crypto"
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { token, password } = body
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json({ error: "Token inválido" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || typeof password !== "string" || password.length < 6) {
|
||||||
|
return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca o token de verificação
|
||||||
|
const verification = await prisma.authVerification.findFirst({
|
||||||
|
where: {
|
||||||
|
value: token,
|
||||||
|
identifier: { startsWith: "password-reset:" },
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verification) {
|
||||||
|
return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrai o userId do identifier
|
||||||
|
const userId = verification.identifier.replace("password-reset:", "")
|
||||||
|
|
||||||
|
// Busca o usuário
|
||||||
|
const user = await prisma.authUser.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash da nova senha
|
||||||
|
const hashedPassword = await hashPassword(password)
|
||||||
|
|
||||||
|
// Atualiza a conta do usuário com a nova senha
|
||||||
|
await prisma.authAccount.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove o token usado
|
||||||
|
await prisma.authVerification.delete({
|
||||||
|
where: { id: verification.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RESET_PASSWORD] Erro:", error)
|
||||||
|
return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET para validar se o token é válido (usado pela página)
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const token = searchParams.get("token")
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ valid: false, error: "Token não fornecido" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await prisma.authVerification.findFirst({
|
||||||
|
where: {
|
||||||
|
value: token,
|
||||||
|
identifier: { startsWith: "password-reset:" },
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verification) {
|
||||||
|
return NextResponse.json({ valid: false, error: "Token inválido ou expirado" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ valid: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RESET_PASSWORD] Erro ao validar token:", error)
|
||||||
|
return NextResponse.json({ valid: false, error: "Erro ao validar token" })
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue