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 push:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(bun run:*)"
|
||||
"Bash(bun run:*)",
|
||||
"Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")",
|
||||
"Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")",
|
||||
"Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")",
|
||||
"Bash(cmd /c \"echo %TEMP%\")",
|
||||
"Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")",
|
||||
"Bash(where:*)",
|
||||
"Bash(ssh-keygen:*)",
|
||||
"Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)",
|
||||
"Bash(npx convex deploy:*)",
|
||||
"Bash(dir \"%LOCALAPPDATA%\\Raven\")",
|
||||
"Bash(dir \"%APPDATA%\\Raven\")",
|
||||
"Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")",
|
||||
"Bash(dir \"%APPDATA%\\com.raven.app\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dir /s /b %LOCALAPPDATA%*raven*)",
|
||||
"Bash(cmd /c \"tasklist | findstr /i raven\")",
|
||||
"Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")",
|
||||
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")",
|
||||
"Bash(node:*)",
|
||||
"Bash(bun scripts/test-all-emails.tsx:*)",
|
||||
"Bash(bun scripts/send-test-react-email.tsx:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(npx convex:*)",
|
||||
"Bash(bun tsc:*)",
|
||||
"Bash(scp:*)",
|
||||
"Bash(docker run:*)",
|
||||
"Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
|
||||
"Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")",
|
||||
"Bash(cmd /c \"docker --version && docker ps -a\")",
|
||||
"Bash(powershell -Command \"docker --version\")",
|
||||
"Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
|
||||
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)",
|
||||
"Bash(bunx prisma migrate:*)",
|
||||
"Bash(bunx prisma db push:*)",
|
||||
"Bash(bun run auth:seed:*)",
|
||||
"Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)",
|
||||
"Bash(bun tsx:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(docker rm:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <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)
|
||||
ARCHIVE_DIR=./archives
|
||||
|
||||
# PostgreSQL database
|
||||
# Para desenvolvimento local, use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||
# PostgreSQL database (versao 18)
|
||||
# Para desenvolvimento local, use Docker:
|
||||
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||
|
||||
# SMTP Configuration (production values in docs/SMTP.md)
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
||||
ENV BUN_INSTALL=/root/.bun
|
||||
|
|
@ -17,9 +17,9 @@ RUN apt-get update -y \
|
|||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Bun 1.3.2
|
||||
# Install Bun 1.3.4
|
||||
RUN curl -fsSL https://bun.sh/install \
|
||||
| bash -s -- bun-v1.3.2 \
|
||||
| bash -s -- bun-v1.3.4 \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx
|
||||
|
||||
|
|
|
|||
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`.
|
||||
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
|
||||
|
||||
## Stack atual (06/11/2025)
|
||||
- **Next.js**: `16.0.8` (Turbopack por padrão; webpack fica como fallback).
|
||||
## Stack atual (18/12/2025)
|
||||
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
|
||||
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
|
||||
- **React / React DOM**: `19.2.0`.
|
||||
- **React / React DOM**: `19.2.1`.
|
||||
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
|
||||
- **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS.
|
||||
- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro.
|
||||
|
|
@ -38,7 +38,7 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
|
|||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=dev-only-long-random-string
|
||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||
DATABASE_URL=file:./prisma/db.dev.sqlite
|
||||
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||
```
|
||||
3. `bun run auth:seed`
|
||||
4. (Opcional) `bun run queues:ensure`
|
||||
|
|
@ -47,8 +47,8 @@ Os demais colaboradores reais são provisionados via **Convites & acessos**. Cas
|
|||
7. Acesse `http://localhost:3000` e valide login com os usuários padrão.
|
||||
|
||||
### Banco de dados
|
||||
- Local (DEV): `DATABASE_URL=file:./prisma/db.dev.sqlite` (guardado em `prisma/prisma/`).
|
||||
- Produção: SQLite persistido no volume Swarm `sistema_sistema_db`. Migrations em PROD devem apontar para esse volume (ver `docs/DEPLOY-RUNBOOK.md`).
|
||||
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
|
||||
- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
|
||||
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
|
||||
|
||||
### Verificações antes de PR/deploy
|
||||
|
|
@ -104,12 +104,12 @@ bun run build:bun
|
|||
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
|
||||
docker service update --force sistema_web
|
||||
```
|
||||
- Resolver `P3009` (migration falhou) sempre no volume `sistema_sistema_db`:
|
||||
- Resolver `P3009` (migration falhou) no PostgreSQL ativo:
|
||||
```bash
|
||||
docker service scale sistema_web=0
|
||||
docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \
|
||||
docker run --rm -it --network traefik_public \
|
||||
--env-file /home/renan/apps/sistema.current/.env \
|
||||
-v /home/renan/apps/sistema.current:/app \
|
||||
-v sistema_sistema_db:/app/data -w /app \
|
||||
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
|
||||
docker service scale sistema_web=1
|
||||
```
|
||||
|
|
@ -164,8 +164,51 @@ bun run build:bun
|
|||
- **Docs complementares**:
|
||||
- `docs/DEV.md` — guia diário atualizado.
|
||||
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
||||
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
|
||||
- `docs/OPERATIONS.md` — runbook do Swarm.
|
||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
||||
|
||||
## Regras de Codigo
|
||||
|
||||
### Tooltips Nativos do Navegador
|
||||
|
||||
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
|
||||
|
||||
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
|
||||
|
||||
```tsx
|
||||
// ERRADO - causa tooltip nativo do navegador
|
||||
<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",
|
||||
"preview": "vite preview",
|
||||
"tauri": "node ./scripts/tauri-with-stub.mjs",
|
||||
"gen:icon": "node ./scripts/build-icon.mjs"
|
||||
"gen:icon": "node ./scripts/build-icon.mjs",
|
||||
"build:service": "cd service && cargo build --release",
|
||||
"build:all": "bun run build:service && bun run tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"convex": "^1.31.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
|
|
|||
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",
|
||||
"chrono",
|
||||
"convex",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"get_if_addrs",
|
||||
"hostname",
|
||||
|
|
@ -80,10 +81,12 @@ dependencies = [
|
|||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
|
|
@ -936,13 +939,34 @@ dependencies = [
|
|||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -953,7 +977,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
|
@ -3627,6 +3651,17 @@ dependencies = [
|
|||
"bitflags 2.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
|
|
@ -4514,7 +4549,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.3",
|
||||
|
|
@ -4564,7 +4599,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
|
|
@ -4748,6 +4783,21 @@ dependencies = [
|
|||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.0"
|
||||
|
|
@ -4771,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
@ -5307,7 +5357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2 0.6.3",
|
||||
|
|
@ -6088,6 +6138,15 @@ dependencies = [
|
|||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
@ -6139,6 +6198,21 @@ dependencies = [
|
|||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6196,6 +6270,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6214,6 +6294,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6232,6 +6318,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6262,6 +6354,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6280,6 +6378,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6298,6 +6402,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6316,6 +6426,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6378,7 +6494,7 @@ dependencies = [
|
|||
"block2 0.6.2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ tauri-plugin-updater = "2.9.0"
|
|||
tauri-plugin-process = "2.3.0"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||
|
|
@ -41,6 +42,8 @@ hostname = "0.4"
|
|||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
convex = "0.10.2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "5"
|
||||
# SSE usa reqwest com stream, nao precisa de websocket
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for all windows",
|
||||
"windows": ["main", "chat-*"],
|
||||
"windows": ["main", "chat-*", "chat-hub"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
"opener:default",
|
||||
"store:default",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,121 @@
|
|||
; Hooks customizadas do instalador NSIS (Tauri)
|
||||
;
|
||||
; Objetivo: remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo.
|
||||
; Objetivo:
|
||||
; - Remover a marca "Nullsoft Install System" exibida no canto inferior esquerdo
|
||||
; - Instalar o Raven Service para operacoes privilegiadas sem UAC
|
||||
;
|
||||
; Nota: o bundler do Tauri injeta estes macros no script principal do instalador.
|
||||
|
||||
BrandingText " "
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
; Para e remove qualquer instancia anterior do servico antes de atualizar
|
||||
DetailPrint "Parando servicos anteriores..."
|
||||
|
||||
; Para o servico
|
||||
nsExec::ExecToLog 'sc stop RavenService'
|
||||
|
||||
; Aguarda o servico parar completamente (ate 10 segundos)
|
||||
nsExec::ExecToLog 'powershell -Command "$$i=0; while((Get-Service RavenService -ErrorAction SilentlyContinue).Status -eq \"Running\" -and $$i -lt 10){Start-Sleep 1;$$i++}"'
|
||||
|
||||
; Remove o servico antigo (IMPORTANTE para reinstalacoes)
|
||||
DetailPrint "Removendo servico antigo..."
|
||||
IfFileExists "$INSTDIR\raven-service.exe" 0 +2
|
||||
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
|
||||
|
||||
; Fallback: remove via sc delete se o executavel nao existir
|
||||
nsExec::ExecToLog 'sc delete RavenService'
|
||||
|
||||
; Forca encerramento de processos remanescentes
|
||||
nsExec::ExecToLog 'taskkill /F /IM raven-service.exe'
|
||||
nsExec::ExecToLog 'taskkill /F /IM appsdesktop.exe'
|
||||
|
||||
; Aguarda liberacao dos arquivos e remocao completa do servico
|
||||
Sleep 3000
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
; =========================================================================
|
||||
; Instala e inicia o Raven Service
|
||||
; =========================================================================
|
||||
|
||||
DetailPrint "Instalando Raven Service..."
|
||||
|
||||
; Garante que nao ha servico residual
|
||||
nsExec::ExecToLog 'sc delete RavenService'
|
||||
Sleep 1000
|
||||
|
||||
; O servico ja esta em $INSTDIR (copiado como resource pelo Tauri)
|
||||
; Registra o servico Windows
|
||||
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
|
||||
Pop $0
|
||||
|
||||
${If} $0 != 0
|
||||
DetailPrint "Aviso: Falha ao registrar servico (codigo: $0)"
|
||||
; Tenta remover completamente e reinstalar
|
||||
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
|
||||
nsExec::ExecToLog 'sc delete RavenService'
|
||||
Sleep 1000
|
||||
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" install'
|
||||
Pop $0
|
||||
${EndIf}
|
||||
|
||||
; Aguarda registro do servico
|
||||
Sleep 500
|
||||
|
||||
; Inicia o servico
|
||||
DetailPrint "Iniciando Raven Service..."
|
||||
nsExec::ExecToLog 'sc start RavenService'
|
||||
Pop $0
|
||||
|
||||
${If} $0 == 0
|
||||
DetailPrint "Raven Service iniciado com sucesso!"
|
||||
${Else}
|
||||
; Tenta novamente apos breve espera
|
||||
Sleep 1000
|
||||
nsExec::ExecToLog 'sc start RavenService'
|
||||
Pop $0
|
||||
${If} $0 == 0
|
||||
DetailPrint "Raven Service iniciado com sucesso (segunda tentativa)!"
|
||||
${Else}
|
||||
DetailPrint "Aviso: Servico sera iniciado na proxima reinicializacao (codigo: $0)"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
; =========================================================================
|
||||
; Verifica se RustDesk esta instalado
|
||||
; Se nao estiver, o Raven Service instalara automaticamente no primeiro uso
|
||||
; =========================================================================
|
||||
|
||||
IfFileExists "$PROGRAMFILES\RustDesk\rustdesk.exe" rustdesk_found rustdesk_not_found
|
||||
|
||||
rustdesk_not_found:
|
||||
DetailPrint "RustDesk sera instalado automaticamente pelo Raven Service."
|
||||
Goto rustdesk_done
|
||||
|
||||
rustdesk_found:
|
||||
DetailPrint "RustDesk ja esta instalado."
|
||||
|
||||
rustdesk_done:
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
; =========================================================================
|
||||
; Para e remove o Raven Service
|
||||
; =========================================================================
|
||||
|
||||
DetailPrint "Parando Raven Service..."
|
||||
nsExec::ExecToLog 'sc stop RavenService'
|
||||
Sleep 1000
|
||||
|
||||
DetailPrint "Removendo Raven Service..."
|
||||
nsExec::ExecToLog '"$INSTDIR\raven-service.exe" uninstall'
|
||||
|
||||
; Aguarda um pouco para garantir que o servico foi removido
|
||||
Sleep 500
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
; Nada adicional necessario
|
||||
!macroend
|
||||
|
||||
|
|
|
|||
|
|
@ -708,7 +708,7 @@ fn collect_windows_extended() -> serde_json::Value {
|
|||
}
|
||||
|
||||
fn decode_utf16_le_to_string(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.len() % 2 != 0 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let utf16: Vec<u16> = bytes
|
||||
|
|
@ -971,6 +971,169 @@ fn collect_windows_extended() -> serde_json::Value {
|
|||
"#).unwrap_or_else(|| json!([]));
|
||||
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
|
||||
|
||||
// Bateria (notebooks/laptops)
|
||||
let battery = ps(r#"
|
||||
$batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime)
|
||||
if ($batteries.Count -eq 0) {
|
||||
[PSCustomObject]@{ Present = $false; Batteries = @() }
|
||||
} else {
|
||||
# Mapeia status numérico para texto
|
||||
$statusMap = @{
|
||||
1 = 'Discharging'
|
||||
2 = 'AC Power'
|
||||
3 = 'Fully Charged'
|
||||
4 = 'Low'
|
||||
5 = 'Critical'
|
||||
6 = 'Charging'
|
||||
7 = 'Charging High'
|
||||
8 = 'Charging Low'
|
||||
9 = 'Charging Critical'
|
||||
10 = 'Undefined'
|
||||
11 = 'Partially Charged'
|
||||
}
|
||||
foreach ($b in $batteries) {
|
||||
if ($b.BatteryStatus) {
|
||||
$b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force
|
||||
}
|
||||
}
|
||||
[PSCustomObject]@{ Present = $true; Batteries = $batteries }
|
||||
}
|
||||
"#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] }));
|
||||
|
||||
// Sensores térmicos (temperatura CPU/GPU quando disponível)
|
||||
let thermal = ps(r#"
|
||||
$temps = @()
|
||||
# Tenta WMI thermal zone (requer admin em alguns sistemas)
|
||||
try {
|
||||
$zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
|
||||
foreach ($z in $zones) {
|
||||
if ($z.CurrentTemperature) {
|
||||
$celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1)
|
||||
$temps += [PSCustomObject]@{
|
||||
Source = 'ThermalZone'
|
||||
Name = $z.InstanceName
|
||||
TemperatureCelsius = $celsius
|
||||
CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
# CPU temp via Open Hardware Monitor WMI (se instalado)
|
||||
try {
|
||||
$ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' }
|
||||
foreach ($s in $ohm) {
|
||||
$temps += [PSCustomObject]@{
|
||||
Source = 'OpenHardwareMonitor'
|
||||
Name = $s.Name
|
||||
TemperatureCelsius = $s.Value
|
||||
Parent = $s.Parent
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@($temps)
|
||||
"#).unwrap_or_else(|| json!([]));
|
||||
|
||||
// Adaptadores de rede (físicos e virtuais)
|
||||
let network_adapters = ps(r#"
|
||||
@(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object {
|
||||
$statusMap = @{
|
||||
0 = 'Disconnected'
|
||||
1 = 'Connecting'
|
||||
2 = 'Connected'
|
||||
3 = 'Disconnecting'
|
||||
4 = 'Hardware not present'
|
||||
5 = 'Hardware disabled'
|
||||
6 = 'Hardware malfunction'
|
||||
7 = 'Media disconnected'
|
||||
8 = 'Authenticating'
|
||||
9 = 'Authentication succeeded'
|
||||
10 = 'Authentication failed'
|
||||
11 = 'Invalid address'
|
||||
12 = 'Credentials required'
|
||||
}
|
||||
$_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force
|
||||
$_
|
||||
})
|
||||
"#).unwrap_or_else(|| json!([]));
|
||||
|
||||
// Monitores conectados
|
||||
let monitors = ps(r#"
|
||||
@(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } }
|
||||
[PSCustomObject]@{
|
||||
ManufacturerName = & $decode $_.ManufacturerName
|
||||
ProductCodeID = & $decode $_.ProductCodeID
|
||||
SerialNumberID = & $decode $_.SerialNumberID
|
||||
UserFriendlyName = & $decode $_.UserFriendlyName
|
||||
YearOfManufacture = $_.YearOfManufacture
|
||||
WeekOfManufacture = $_.WeekOfManufacture
|
||||
}
|
||||
})
|
||||
"#).unwrap_or_else(|| json!([]));
|
||||
|
||||
// Fonte de alimentação / chassis
|
||||
let power_supply = ps(r#"
|
||||
$chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag
|
||||
$chassisTypeMap = @{
|
||||
1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop'
|
||||
5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable'
|
||||
9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station'
|
||||
13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box'
|
||||
17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis'
|
||||
20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis'
|
||||
23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis'
|
||||
30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable'
|
||||
}
|
||||
$types = @()
|
||||
if ($chassis.ChassisTypes) {
|
||||
foreach ($t in $chassis.ChassisTypes) {
|
||||
$types += $chassisTypeMap[[int]$t] ?? "Type$t"
|
||||
}
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
ChassisTypes = $chassis.ChassisTypes
|
||||
ChassisTypesText = $types
|
||||
Manufacturer = $chassis.Manufacturer
|
||||
SerialNumber = $chassis.SerialNumber
|
||||
SMBIOSAssetTag = $chassis.SMBIOSAssetTag
|
||||
}
|
||||
"#).unwrap_or_else(|| json!({}));
|
||||
|
||||
// Último reinício e contagem de boots
|
||||
let boot_info = ps(r#"
|
||||
$os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime
|
||||
$lastBoot = $os.LastBootUpTime
|
||||
|
||||
# Calcula uptime
|
||||
$uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 }
|
||||
|
||||
# Conta eventos de boot (ID 6005) - últimos 30 dias para performance
|
||||
$startDate = (Get-Date).AddDays(-30)
|
||||
$bootEvents = @()
|
||||
$bootCount = 0
|
||||
try {
|
||||
$events = Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'System'
|
||||
ID = 6005
|
||||
StartTime = $startDate
|
||||
} -MaxEvents 50 -ErrorAction SilentlyContinue
|
||||
$bootCount = @($events).Count
|
||||
$bootEvents = @($events | Select-Object -First 10 | ForEach-Object {
|
||||
@{
|
||||
TimeCreated = $_.TimeCreated.ToString('o')
|
||||
Computer = $_.MachineName
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
|
||||
[PSCustomObject]@{
|
||||
LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null }
|
||||
UptimeSeconds = [math]::Round($uptime)
|
||||
BootCountLast30Days = $bootCount
|
||||
RecentBoots = $bootEvents
|
||||
}
|
||||
"#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] }));
|
||||
|
||||
json!({
|
||||
"windows": {
|
||||
"software": software,
|
||||
|
|
@ -992,6 +1155,12 @@ fn collect_windows_extended() -> serde_json::Value {
|
|||
"windowsUpdate": windows_update,
|
||||
"computerSystem": computer_system,
|
||||
"azureAdStatus": device_join,
|
||||
"battery": battery,
|
||||
"thermal": thermal,
|
||||
"networkAdapters": network_adapters,
|
||||
"monitors": monitors,
|
||||
"chassis": power_supply,
|
||||
"bootInfo": boot_info,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1086,7 +1255,7 @@ pub fn collect_profile() -> Result<MachineProfile, AgentError> {
|
|||
let system = collect_system();
|
||||
|
||||
let os_name = System::name()
|
||||
.or_else(|| System::long_os_version())
|
||||
.or_else(System::long_os_version)
|
||||
.unwrap_or_else(|| "desconhecido".to_string());
|
||||
let os_version = System::os_version();
|
||||
let architecture = std::env::consts::ARCH.to_string();
|
||||
|
|
@ -1146,7 +1315,7 @@ async fn post_heartbeat(
|
|||
.into_owned();
|
||||
let os = MachineOs {
|
||||
name: System::name()
|
||||
.or_else(|| System::long_os_version())
|
||||
.or_else(System::long_os_version)
|
||||
.unwrap_or_else(|| "desconhecido".to_string()),
|
||||
version: System::os_version(),
|
||||
architecture: Some(std::env::consts::ARCH.to_string()),
|
||||
|
|
@ -1225,7 +1394,8 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
|||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::usb_control::{apply_usb_policy, get_current_policy, UsbPolicy};
|
||||
use crate::usb_control::{get_current_policy, UsbPolicy};
|
||||
use crate::service_client;
|
||||
|
||||
let policy = match UsbPolicy::from_str(&policy_str) {
|
||||
Some(p) => p,
|
||||
|
|
@ -1259,24 +1429,58 @@ async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
|||
// Reporta APPLYING para progress bar real no frontend
|
||||
let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await;
|
||||
|
||||
match apply_usb_policy(policy) {
|
||||
// Tenta primeiro via RavenService (privilegiado)
|
||||
crate::log_info!("Tentando aplicar politica via RavenService...");
|
||||
match service_client::apply_usb_policy(&policy_str) {
|
||||
Ok(result) => {
|
||||
crate::log_info!("Politica USB aplicada com sucesso: {:?}", result);
|
||||
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
|
||||
if !reported {
|
||||
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
|
||||
// Agenda retry em background
|
||||
let base_url = base_url.to_string();
|
||||
let token = token.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
crate::log_info!("Retry agendado: reportando politica USB...");
|
||||
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
|
||||
});
|
||||
if result.success {
|
||||
crate::log_info!("Politica USB aplicada com sucesso via RavenService: {:?}", result);
|
||||
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
|
||||
if !reported {
|
||||
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
|
||||
let base_url = base_url.to_string();
|
||||
let token = token.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
crate::log_info!("Retry agendado: reportando politica USB...");
|
||||
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
let err_msg = result.error.unwrap_or_else(|| "Erro desconhecido".to_string());
|
||||
crate::log_error!("RavenService retornou erro: {}", err_msg);
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
|
||||
}
|
||||
}
|
||||
Err(service_client::ServiceClientError::ServiceUnavailable(msg)) => {
|
||||
crate::log_warn!("RavenService nao disponivel: {}", msg);
|
||||
// Tenta fallback direto (vai falhar se nao tiver privilegio)
|
||||
crate::log_info!("Tentando aplicar politica diretamente...");
|
||||
match crate::usb_control::apply_usb_policy(policy) {
|
||||
Ok(result) => {
|
||||
crate::log_info!("Politica USB aplicada com sucesso (direto): {:?}", result);
|
||||
let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await;
|
||||
if !reported {
|
||||
crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!");
|
||||
let base_url = base_url.to_string();
|
||||
let token = token.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
crate::log_info!("Retry agendado: reportando politica USB...");
|
||||
let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e);
|
||||
crate::log_error!("{}", err_msg);
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_error!("Falha ao aplicar politica USB: {e}");
|
||||
crate::log_error!("Falha ao comunicar com RavenService: {e}");
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ use once_cell::sync::Lazy;
|
|||
use parking_lot::Mutex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
|
@ -100,6 +102,77 @@ pub struct SessionStartedEvent {
|
|||
pub session: ChatSession,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PERSISTENCIA DE ESTADO
|
||||
// ============================================================================
|
||||
|
||||
/// Estado persistido do chat para sobreviver a restarts
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChatPersistedState {
|
||||
last_unread_count: u32,
|
||||
sessions: Vec<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
|
||||
// ============================================================================
|
||||
|
|
@ -321,6 +394,7 @@ pub struct UploadResult {
|
|||
// Extensoes permitidas
|
||||
const ALLOWED_EXTENSIONS: &[&str] = &[
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||
".mp3", ".wav", ".ogg", ".webm", ".m4a",
|
||||
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
||||
];
|
||||
|
||||
|
|
@ -361,6 +435,11 @@ pub fn get_mime_type(file_name: &str) -> String {
|
|||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"mp3" => "audio/mpeg",
|
||||
"wav" => "audio/wav",
|
||||
"ogg" => "audio/ogg",
|
||||
"webm" => "audio/webm",
|
||||
"m4a" => "audio/mp4",
|
||||
"pdf" => "application/pdf",
|
||||
"txt" => "text/plain",
|
||||
"doc" => "application/msword",
|
||||
|
|
@ -462,10 +541,16 @@ pub struct ChatRuntime {
|
|||
|
||||
impl ChatRuntime {
|
||||
pub fn new() -> Self {
|
||||
// Tentar restaurar estado persistido
|
||||
let (sessions, unread) = match load_chat_state() {
|
||||
Some(state) => (state.sessions, state.last_unread_count),
|
||||
None => (Vec::new(), 0),
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
last_sessions: Arc::new(Mutex::new(Vec::new())),
|
||||
last_unread_count: Arc::new(Mutex::new(0)),
|
||||
last_sessions: Arc::new(Mutex::new(sessions)),
|
||||
last_unread_count: Arc::new(Mutex::new(unread)),
|
||||
is_connected: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
|
@ -510,7 +595,9 @@ impl ChatRuntime {
|
|||
let is_connected = self.is_connected.clone();
|
||||
|
||||
let join_handle = tauri::async_runtime::spawn(async move {
|
||||
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)");
|
||||
crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat");
|
||||
crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone);
|
||||
crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone);
|
||||
|
||||
let mut backoff_ms: u64 = 1_000;
|
||||
let max_backoff_ms: u64 = 30_000;
|
||||
|
|
@ -522,12 +609,16 @@ impl ChatRuntime {
|
|||
break;
|
||||
}
|
||||
|
||||
crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex...");
|
||||
let client_result = ConvexClient::new(&convex_clone).await;
|
||||
let mut client = match client_result {
|
||||
Ok(c) => c,
|
||||
Ok(c) => {
|
||||
crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso");
|
||||
c
|
||||
}
|
||||
Err(err) => {
|
||||
is_connected.store(false, Ordering::Relaxed);
|
||||
crate::log_warn!("Falha ao criar cliente Convex: {err:?}");
|
||||
crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}");
|
||||
|
||||
if last_poll.elapsed() >= poll_interval {
|
||||
poll_and_process_chat_update(
|
||||
|
|
@ -550,16 +641,18 @@ impl ChatRuntime {
|
|||
let mut args = BTreeMap::new();
|
||||
args.insert("machineToken".to_string(), token_clone.clone().into());
|
||||
|
||||
crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates...");
|
||||
let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await;
|
||||
let mut subscription = match subscribe_result {
|
||||
Ok(sub) => {
|
||||
is_connected.store(true, Ordering::Relaxed);
|
||||
backoff_ms = 1_000;
|
||||
crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!");
|
||||
sub
|
||||
}
|
||||
Err(err) => {
|
||||
is_connected.store(false, Ordering::Relaxed);
|
||||
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}");
|
||||
crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}");
|
||||
|
||||
if last_poll.elapsed() >= poll_interval {
|
||||
poll_and_process_chat_update(
|
||||
|
|
@ -579,8 +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 {
|
||||
update_count += 1;
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop");
|
||||
break;
|
||||
}
|
||||
match next {
|
||||
|
|
@ -601,6 +698,11 @@ impl ChatRuntime {
|
|||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
crate::log_info!(
|
||||
"[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}",
|
||||
update_count, has_active, total_unread
|
||||
);
|
||||
|
||||
process_chat_update(
|
||||
&base_clone,
|
||||
&token_clone,
|
||||
|
|
@ -613,13 +715,13 @@ impl ChatRuntime {
|
|||
.await;
|
||||
}
|
||||
FunctionResult::ConvexError(err) => {
|
||||
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}");
|
||||
crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}");
|
||||
}
|
||||
FunctionResult::ErrorMessage(msg) => {
|
||||
crate::log_warn!("Erro em checkMachineUpdates: {msg}");
|
||||
crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}");
|
||||
}
|
||||
FunctionResult::Value(other) => {
|
||||
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}");
|
||||
crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -627,10 +729,11 @@ impl ChatRuntime {
|
|||
is_connected.store(false, Ordering::Relaxed);
|
||||
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop");
|
||||
break;
|
||||
}
|
||||
|
||||
crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar");
|
||||
crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar...");
|
||||
if last_poll.elapsed() >= poll_interval {
|
||||
poll_and_process_chat_update(
|
||||
&base_clone,
|
||||
|
|
@ -684,8 +787,13 @@ async fn poll_and_process_chat_update(
|
|||
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
||||
last_unread_count: &Arc<Mutex<u32>>,
|
||||
) {
|
||||
crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling...");
|
||||
match poll_chat_updates(base_url, token, None).await {
|
||||
Ok(result) => {
|
||||
crate::log_info!(
|
||||
"[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}",
|
||||
result.has_active_sessions, result.total_unread
|
||||
);
|
||||
process_chat_update(
|
||||
base_url,
|
||||
token,
|
||||
|
|
@ -698,7 +806,7 @@ async fn poll_and_process_chat_update(
|
|||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
crate::log_warn!("Chat fallback poll falhou: {err}");
|
||||
crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -712,10 +820,18 @@ async fn process_chat_update(
|
|||
has_active_sessions: bool,
|
||||
total_unread: u32,
|
||||
) {
|
||||
crate::log_info!(
|
||||
"[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}",
|
||||
has_active_sessions, total_unread
|
||||
);
|
||||
|
||||
// Buscar sessoes completas para ter dados corretos
|
||||
let mut current_sessions = if has_active_sessions {
|
||||
fetch_sessions(base_url, token).await.unwrap_or_default()
|
||||
let sessions = fetch_sessions(base_url, token).await.unwrap_or_default();
|
||||
crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len());
|
||||
sessions
|
||||
} else {
|
||||
crate::log_info!("[CHAT DEBUG] Sem sessoes ativas");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
|
|
@ -776,14 +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 new_messages = total_unread > prev_unread;
|
||||
|
||||
// Estrategia 1: Detectar por lastActivityAt de cada sessao
|
||||
// Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem
|
||||
let mut detected_by_activity = false;
|
||||
let mut activity_details = String::new();
|
||||
|
||||
for session in ¤t_sessions {
|
||||
let prev_activity = prev_sessions
|
||||
.iter()
|
||||
.find(|s| s.session_id == session.session_id)
|
||||
.map(|s| s.last_activity_at)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente
|
||||
if session.last_activity_at > prev_activity && session.unread_count > 0 {
|
||||
detected_by_activity = true;
|
||||
activity_details = format!(
|
||||
"sessao={} activity: {} -> {} unread={}",
|
||||
session.ticket_id, prev_activity, session.last_activity_at, session.unread_count
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Estrategia 2: Fallback por contador total (metodo original)
|
||||
let detected_by_count = total_unread > prev_unread;
|
||||
|
||||
// Nova mensagem se QUALQUER estrategia detectar
|
||||
let new_messages = detected_by_activity || detected_by_count;
|
||||
|
||||
// Log detalhado para diagnostico
|
||||
crate::log_info!(
|
||||
"[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}",
|
||||
detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages
|
||||
);
|
||||
if detected_by_activity {
|
||||
crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details);
|
||||
}
|
||||
|
||||
// Atualizar caches APOS deteccao (importante: manter ordem)
|
||||
*last_sessions.lock() = current_sessions.clone();
|
||||
*last_unread_count.lock() = total_unread;
|
||||
|
||||
// Persistir estado para sobreviver a restarts
|
||||
save_chat_state(total_unread, ¤t_sessions);
|
||||
|
||||
// Sempre emitir unread-update
|
||||
let _ = app.emit(
|
||||
"raven://chat/unread-update",
|
||||
|
|
@ -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
|
||||
if new_messages && total_unread > 0 {
|
||||
let new_count = total_unread - prev_unread;
|
||||
let new_count = if total_unread > prev_unread {
|
||||
total_unread - prev_unread
|
||||
} else {
|
||||
1 // Se detectou por activity mas contador nao mudou, assumir 1 nova
|
||||
};
|
||||
|
||||
crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread);
|
||||
crate::log_info!(
|
||||
"[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}",
|
||||
new_count, total_unread,
|
||||
if detected_by_activity { "activity" } else { "count" }
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"raven://chat/new-message",
|
||||
|
|
@ -838,37 +1012,34 @@ async fn process_chat_update(
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
||||
let session_to_show = if best_delta > 0 {
|
||||
best_session
|
||||
} else {
|
||||
current_sessions.iter().max_by(|a, b| {
|
||||
a.unread_count
|
||||
.cmp(&b.unread_count)
|
||||
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
|
||||
})
|
||||
};
|
||||
|
||||
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra)
|
||||
if let Some(session) = session_to_show {
|
||||
let label = format!("chat-{}", session.ticket_id);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida)
|
||||
// Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens
|
||||
let _ = window.show();
|
||||
// Verificar se esta expandida (altura > 100px significa expandido)
|
||||
// Se estiver expandida, NAO minimizar - usuario esta usando o chat
|
||||
if let Ok(size) = window.inner_size() {
|
||||
let is_expanded = size.height > 100;
|
||||
if !is_expanded {
|
||||
// Janela esta minimizada, manter minimizada
|
||||
let _ = set_chat_minimized(app, &session.ticket_id, true);
|
||||
}
|
||||
// Se esta expandida, nao faz nada - deixa o usuario continuar usando
|
||||
}
|
||||
// Se ha multiplas sessoes ativas, usar o hub quando nao houver chat expandido.
|
||||
//
|
||||
// Importante (UX): nao mostrar hub e chat ao mesmo tempo.
|
||||
if current_sessions.len() > 1 {
|
||||
if has_expanded_chat_window() {
|
||||
let _ = close_hub_window(app);
|
||||
} else {
|
||||
// Criar nova janela ja minimizada (menos intrusivo)
|
||||
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
|
||||
close_all_chat_windows(app);
|
||||
let _ = open_hub_window(app);
|
||||
}
|
||||
} else {
|
||||
// Uma sessao - nao precisa de hub
|
||||
let _ = close_hub_window(app);
|
||||
|
||||
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
||||
let session_to_show = if best_delta > 0 {
|
||||
best_session
|
||||
} else {
|
||||
current_sessions.iter().max_by(|a, b| {
|
||||
a.unread_count
|
||||
.cmp(&b.unread_count)
|
||||
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
|
||||
})
|
||||
};
|
||||
|
||||
// Mostrar janela de chat (sempre minimizada/nao intrusiva)
|
||||
if let Some(session) = session_to_show {
|
||||
let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -885,6 +1056,16 @@ async fn process_chat_update(
|
|||
.title(notification_title)
|
||||
.body(¬ification_body)
|
||||
.show();
|
||||
} else {
|
||||
// Log para debug quando NAO ha novas mensagens
|
||||
if total_unread == 0 {
|
||||
crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)");
|
||||
} else if !new_messages {
|
||||
crate::log_info!(
|
||||
"[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})",
|
||||
prev_unread, total_unread
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -892,6 +1073,53 @@ async fn process_chat_update(
|
|||
// WINDOW MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
|
||||
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
static CHAT_WINDOW_STATE: Lazy<Mutex<HashMap<String, bool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn set_chat_window_state(label: &str, minimized: bool) {
|
||||
CHAT_WINDOW_STATE.lock().insert(label.to_string(), minimized);
|
||||
}
|
||||
|
||||
fn clear_chat_window_state(label: &str) {
|
||||
CHAT_WINDOW_STATE.lock().remove(label);
|
||||
}
|
||||
|
||||
fn has_expanded_chat_window() -> bool {
|
||||
CHAT_WINDOW_STATE.lock().values().any(|minimized| !*minimized)
|
||||
}
|
||||
|
||||
fn close_all_chat_windows(app: &tauri::AppHandle) {
|
||||
let labels: Vec<String> = app
|
||||
.webview_windows()
|
||||
.keys()
|
||||
.filter(|label| label.starts_with("chat-") && *label != HUB_WINDOW_LABEL)
|
||||
.cloned()
|
||||
.collect();
|
||||
for label in labels {
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.close();
|
||||
}
|
||||
clear_chat_window_state(&label);
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_other_chat_windows(app: &tauri::AppHandle, active_label: &str) {
|
||||
for (label, window) in app.webview_windows() {
|
||||
if !label.starts_with("chat-") {
|
||||
continue;
|
||||
}
|
||||
if label == active_label {
|
||||
continue;
|
||||
}
|
||||
let _ = window.hide();
|
||||
set_chat_window_state(&label, true);
|
||||
}
|
||||
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||
let _ = hub.hide();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_chat_window_position(
|
||||
app: &tauri::AppHandle,
|
||||
window: Option<&tauri::WebviewWindow>,
|
||||
|
|
@ -932,18 +1160,44 @@ fn resolve_chat_window_position(
|
|||
(x, y)
|
||||
}
|
||||
|
||||
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
||||
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada
|
||||
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
|
||||
}
|
||||
|
||||
/// Abre janela de chat com estado inicial de minimizacao configuravel
|
||||
fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
|
||||
let label = format!("chat-{}", ticket_id);
|
||||
crate::log_info!(
|
||||
"[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}",
|
||||
label,
|
||||
ticket_ref,
|
||||
start_minimized
|
||||
);
|
||||
|
||||
if !start_minimized {
|
||||
hide_other_chat_windows(app, &label);
|
||||
}
|
||||
|
||||
// Verificar se ja existe
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
crate::log_info!("[WINDOW] {}: window existe -> show()", label);
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
let _ = window.unminimize();
|
||||
if !start_minimized {
|
||||
crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label);
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Expandir a janela se estiver minimizada (quando clicado na lista)
|
||||
if !start_minimized {
|
||||
crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label);
|
||||
let _ = set_chat_minimized_unlocked(app, ticket_id, false);
|
||||
}
|
||||
crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label);
|
||||
if !start_minimized {
|
||||
set_chat_window_state(&label, false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -960,7 +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
|
||||
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
crate::log_info!(
|
||||
"[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}",
|
||||
label,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
url_path
|
||||
);
|
||||
|
||||
let window = WebviewWindowBuilder::new(
|
||||
app,
|
||||
&label,
|
||||
WebviewUrl::App(url_path.into()),
|
||||
|
|
@ -972,46 +1236,64 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
|
|||
.decorations(false) // Sem decoracoes nativas - usa header customizado
|
||||
.transparent(true) // Permite fundo transparente
|
||||
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
|
||||
.resizable(false) // Desabilitar redimensionamento manual
|
||||
// Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(true)
|
||||
.focused(!start_minimized)
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
crate::log_info!("[WINDOW] {}: build() OK", label);
|
||||
|
||||
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
|
||||
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized);
|
||||
// Reaplica layout/posicao logo apos criar a janela.
|
||||
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
|
||||
let _ = set_chat_minimized(app, ticket_id, start_minimized);
|
||||
let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized);
|
||||
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized);
|
||||
|
||||
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
||||
open_chat_window_internal(app, ticket_id, ticket_ref)
|
||||
// Quando chamado explicitamente (ex: clique no hub), abre expandida
|
||||
open_chat_window_internal(app, ticket_id, ticket_ref, false)
|
||||
}
|
||||
|
||||
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
let label = format!("chat-{}", ticket_id);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
window.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
clear_chat_window_state(&label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
let label = format!("chat-{}", ticket_id);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
set_chat_window_state(&label, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
|
||||
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
||||
fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
||||
let label = format!("chat-{}", ticket_id);
|
||||
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?;
|
||||
|
||||
if minimized {
|
||||
hide_other_chat_windows(app, &label);
|
||||
}
|
||||
|
||||
// Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1)
|
||||
let (width, height) = if minimized {
|
||||
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
|
||||
|
|
@ -1023,9 +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);
|
||||
|
||||
// Aplicar novo tamanho e posicao
|
||||
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized);
|
||||
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
||||
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized);
|
||||
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized);
|
||||
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
|
||||
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized);
|
||||
|
||||
set_chat_window_state(&label, minimized);
|
||||
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
set_chat_minimized_unlocked(app, ticket_id, minimized)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HUB WINDOW MANAGEMENT (Lista de todas as sessoes)
|
||||
// ============================================================================
|
||||
|
||||
const HUB_WINDOW_LABEL: &str = "chat-hub";
|
||||
|
||||
pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
open_hub_window_with_state(app, true) // Por padrao abre minimizada
|
||||
}
|
||||
|
||||
fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> {
|
||||
// Verificar se ja existe
|
||||
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
let _ = window.unminimize();
|
||||
if !start_minimized {
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dimensoes baseadas no estado inicial
|
||||
let (width, height) = if start_minimized {
|
||||
(200.0, 52.0) // Tamanho minimizado (chip)
|
||||
} else {
|
||||
(400.0, 520.0) // Tamanho expandido (igual ao web)
|
||||
};
|
||||
|
||||
// Posicionar no canto inferior direito
|
||||
let (x, y) = resolve_chat_window_position(app, None, width, height);
|
||||
|
||||
// URL para modo hub
|
||||
let url_path = "index.html?view=chat&hub=true";
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
app,
|
||||
HUB_WINDOW_LABEL,
|
||||
WebviewUrl::App(url_path.into()),
|
||||
)
|
||||
.title("Chats de Suporte")
|
||||
.inner_size(width, height)
|
||||
.min_inner_size(200.0, 52.0)
|
||||
.position(x, y)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.resizable(false) // Desabilitar redimensionamento manual
|
||||
// Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(!start_minimized)
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
|
||||
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||
let _ = hub.set_ignore_cursor_events(false);
|
||||
if !start_minimized {
|
||||
let _ = hub.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
// REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar
|
||||
// "resize em cima do resize" no timing errado do WebView2
|
||||
// let _ = set_hub_minimized(app, start_minimized);
|
||||
|
||||
crate::log_info!("Hub window aberta (minimizada={})", start_minimized);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||
window.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> {
|
||||
let _guard = WINDOW_OP_LOCK.lock();
|
||||
let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?;
|
||||
|
||||
let (width, height) = if minimized {
|
||||
(200.0, 52.0) // Chip minimizado
|
||||
} else {
|
||||
(400.0, 520.0) // Lista expandida (igual ao web)
|
||||
};
|
||||
|
||||
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
||||
|
||||
// IGUAL AO CHAT: primeiro size, depois position (ordem importa para hit-test no Windows)
|
||||
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
||||
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
|
||||
|
||||
// Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat).
|
||||
if !minimized {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ mod agent;
|
|||
mod chat;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod rustdesk;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod service_client;
|
||||
mod usb_control;
|
||||
|
||||
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
||||
|
|
@ -68,21 +70,21 @@ pub fn log_agent(level: &str, message: &str) {
|
|||
#[macro_export]
|
||||
macro_rules! log_info {
|
||||
($($arg:tt)*) => {
|
||||
$crate::log_agent("INFO", &format!($($arg)*))
|
||||
$crate::log_agent("INFO", format!($($arg)*).as_str())
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_error {
|
||||
($($arg:tt)*) => {
|
||||
$crate::log_agent("ERROR", &format!($($arg)*))
|
||||
$crate::log_agent("ERROR", format!($($arg)*).as_str())
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_warn {
|
||||
($($arg:tt)*) => {
|
||||
$crate::log_agent("WARN", &format!($($arg)*))
|
||||
$crate::log_agent("WARN", format!($($arg)*).as_str())
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +191,32 @@ fn run_rustdesk_ensure(
|
|||
password: Option<String>,
|
||||
machine_id: Option<String>,
|
||||
) -> Result<RustdeskProvisioningResult, String> {
|
||||
// Tenta usar o servico primeiro (sem UAC)
|
||||
if service_client::is_service_available() {
|
||||
log_info!("Usando Raven Service para provisionar RustDesk");
|
||||
match service_client::provision_rustdesk(
|
||||
config_string.as_deref(),
|
||||
password.as_deref(),
|
||||
machine_id.as_deref(),
|
||||
) {
|
||||
Ok(result) => {
|
||||
return Ok(RustdeskProvisioningResult {
|
||||
id: result.id,
|
||||
password: result.password,
|
||||
installed_version: result.installed_version,
|
||||
updated: result.updated,
|
||||
last_provisioned_at: result.last_provisioned_at,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Falha ao usar servico para RustDesk: {e}");
|
||||
// Continua para fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: chamada direta (pode pedir UAC)
|
||||
log_info!("Usando chamada direta para provisionar RustDesk (pode pedir UAC)");
|
||||
rustdesk::ensure_rustdesk(
|
||||
config_string.as_deref(),
|
||||
password.as_deref(),
|
||||
|
|
@ -208,14 +236,50 @@ fn run_rustdesk_ensure(
|
|||
|
||||
#[tauri::command]
|
||||
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))?;
|
||||
|
||||
usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string())
|
||||
// Tenta usar o servico primeiro (sem UAC)
|
||||
#[cfg(target_os = "windows")]
|
||||
if service_client::is_service_available() {
|
||||
log_info!("Usando Raven Service para aplicar politica USB: {}", policy);
|
||||
match service_client::apply_usb_policy(&policy) {
|
||||
Ok(result) => {
|
||||
return Ok(UsbPolicyResult {
|
||||
success: result.success,
|
||||
policy: result.policy,
|
||||
error: result.error,
|
||||
applied_at: result.applied_at,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Falha ao usar servico para USB policy: {e}");
|
||||
// Continua para fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: chamada direta (pode pedir UAC)
|
||||
log_info!("Usando chamada direta para aplicar politica USB (pode pedir UAC)");
|
||||
usb_control::apply_usb_policy(_policy_enum).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_usb_policy() -> Result<String, String> {
|
||||
// Tenta usar o servico primeiro
|
||||
#[cfg(target_os = "windows")]
|
||||
if service_client::is_service_available() {
|
||||
match service_client::get_usb_policy() {
|
||||
Ok(policy) => return Ok(policy),
|
||||
Err(e) => {
|
||||
log_warn!("Falha ao obter USB policy via servico: {e}");
|
||||
// Continua para fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: leitura direta (nao precisa elevacao para ler)
|
||||
usb_control::get_current_policy()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
@ -346,8 +410,17 @@ async fn upload_chat_file(
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
|
||||
chat::open_chat_window(&app, &ticket_id, ticket_ref)
|
||||
async fn open_chat_window(app: tauri::AppHandle, ticket_id: String, ticket_ref: u64) -> Result<(), String> {
|
||||
log_info!("[CMD] open_chat_window called: ticket_id={}, ticket_ref={}", ticket_id, ticket_ref);
|
||||
let app_handle = app.clone();
|
||||
let ticket_id_for_task = ticket_id.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
chat::open_chat_window(&app_handle, &ticket_id_for_task, ticket_ref)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Falha ao abrir chat (join): {err}"))?;
|
||||
log_info!("[CMD] open_chat_window result: {:?}", result);
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -365,6 +438,26 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
|
|||
chat::set_chat_minimized(&app, &ticket_id, minimized)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
chat::open_hub_window(&app_handle)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Falha ao abrir hub (join): {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
chat::close_hub_window(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> {
|
||||
chat::set_hub_minimized(&app, minimized)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler de Deep Link (raven://)
|
||||
// ============================================================================
|
||||
|
|
@ -452,6 +545,14 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
// Quando uma segunda instância tenta iniciar, foca a janela existente
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
|
|
@ -481,7 +582,7 @@ pub fn run() {
|
|||
{
|
||||
let start_in_background = std::env::args().any(|arg| arg == "--background");
|
||||
setup_raven_autostart();
|
||||
setup_tray(&app.handle())?;
|
||||
setup_tray(app.handle())?;
|
||||
if start_in_background {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.hide();
|
||||
|
|
@ -526,7 +627,11 @@ pub fn run() {
|
|||
open_chat_window,
|
||||
close_chat_window,
|
||||
minimize_chat_window,
|
||||
set_chat_minimized
|
||||
set_chat_minimized,
|
||||
// Hub commands
|
||||
open_hub_window,
|
||||
close_hub_window,
|
||||
set_hub_minimized
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
@ -608,7 +713,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
|||
// Abrir janela de chat se houver sessao ativa
|
||||
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
||||
let sessions = chat_runtime.get_sessions();
|
||||
if let Some(session) = sessions.first() {
|
||||
if sessions.len() > 1 {
|
||||
// Multiplas sessoes - abrir hub
|
||||
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
|
||||
log_error!("Falha ao abrir hub de chat: {e}");
|
||||
}
|
||||
} else if let Some(session) = sessions.first() {
|
||||
// Uma sessao - abrir diretamente
|
||||
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
|
||||
log_error!("Falha ao abrir janela de chat: {e}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#![cfg(target_os = "windows")]
|
||||
|
||||
use crate::RustdeskProvisioningResult;
|
||||
use chrono::{Local, Utc};
|
||||
use once_cell::sync::Lazy;
|
||||
|
|
@ -30,7 +28,9 @@ const LOCAL_SERVICE_CONFIG: &str = r"C:\\Windows\\ServiceProfiles\\LocalService\
|
|||
const LOCAL_SYSTEM_CONFIG: &str = r"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Roaming\\RustDesk\\config";
|
||||
const APP_IDENTIFIER: &str = "br.com.esdrasrenan.sistemadechamados";
|
||||
const MACHINE_STORE_FILENAME: &str = "machine-agent.json";
|
||||
#[allow(dead_code)]
|
||||
const ACL_FLAG_FILENAME: &str = "rustdesk_acl_unlocked.flag";
|
||||
#[allow(dead_code)]
|
||||
const RUSTDESK_ACL_STORE_KEY: &str = "rustdeskAclUnlockedAt";
|
||||
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
|
||||
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
|
||||
|
|
@ -85,11 +85,11 @@ fn define_custom_id_from_machine(exe_path: &Path, machine_id: Option<&str>) -> O
|
|||
}) {
|
||||
match set_custom_id(exe_path, value) {
|
||||
Ok(custom) => {
|
||||
log_event(&format!("ID determinístico definido: {custom}"));
|
||||
log_event(format!("ID determinístico definido: {custom}"));
|
||||
Some(custom)
|
||||
}
|
||||
Err(error) => {
|
||||
log_event(&format!("Falha ao definir ID determinístico: {error}"));
|
||||
log_event(format!("Falha ao definir ID determinístico: {error}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ pub fn ensure_rustdesk(
|
|||
log_event("Iniciando preparo do RustDesk");
|
||||
|
||||
if let Err(error) = ensure_service_profiles_writable_preflight() {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Aviso: não foi possível preparar ACL dos perfis do serviço ({error}). Continuando mesmo assim; o serviço pode não aplicar a senha."
|
||||
));
|
||||
}
|
||||
|
|
@ -116,7 +116,7 @@ pub fn ensure_rustdesk(
|
|||
// Isso preserva o ID quando o Raven é reinstalado mas o RustDesk permanece
|
||||
let preserved_remote_id = read_remote_id_from_profiles();
|
||||
if let Some(ref id) = preserved_remote_id {
|
||||
log_event(&format!("ID existente preservado antes da limpeza: {}", id));
|
||||
log_event(format!("ID existente preservado antes da limpeza: {}", id));
|
||||
}
|
||||
|
||||
let exe_path = detect_executable_path();
|
||||
|
|
@ -129,7 +129,7 @@ pub fn ensure_rustdesk(
|
|||
|
||||
match stop_rustdesk_processes() {
|
||||
Ok(_) => log_event("Instâncias existentes do RustDesk encerradas"),
|
||||
Err(error) => log_event(&format!(
|
||||
Err(error) => log_event(format!(
|
||||
"Aviso: não foi possível parar completamente o RustDesk antes da reprovisionamento ({error})"
|
||||
)),
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ pub fn ensure_rustdesk(
|
|||
if freshly_installed {
|
||||
match purge_existing_rustdesk_profiles() {
|
||||
Ok(_) => log_event("Configurações antigas do RustDesk limpas (instalação fresca)"),
|
||||
Err(error) => log_event(&format!(
|
||||
Err(error) => log_event(format!(
|
||||
"Aviso: não foi possível limpar completamente os perfis existentes do RustDesk ({error})"
|
||||
)),
|
||||
}
|
||||
|
|
@ -152,19 +152,19 @@ pub fn ensure_rustdesk(
|
|||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
}) {
|
||||
if let Err(error) = run_with_args(&exe_path, &["--config", value]) {
|
||||
log_event(&format!("Falha ao aplicar configuração inline: {error}"));
|
||||
log_event(format!("Falha ao aplicar configuração inline: {error}"));
|
||||
} else {
|
||||
log_event("Configuração aplicada via --config");
|
||||
}
|
||||
} else {
|
||||
let config_path = write_config_files()?;
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Arquivo de configuração atualizado em {}",
|
||||
config_path.display()
|
||||
));
|
||||
|
||||
if let Err(error) = apply_config(&exe_path, &config_path) {
|
||||
log_event(&format!("Falha ao aplicar configuração via CLI: {error}"));
|
||||
log_event(format!("Falha ao aplicar configuração via CLI: {error}"));
|
||||
} else {
|
||||
log_event("Configuração aplicada via CLI");
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ pub fn ensure_rustdesk(
|
|||
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
||||
|
||||
if let Err(error) = set_password(&exe_path, &password) {
|
||||
log_event(&format!("Falha ao definir senha padrão: {error}"));
|
||||
log_event(format!("Falha ao definir senha padrão: {error}"));
|
||||
} else {
|
||||
log_event("Senha padrão definida com sucesso");
|
||||
log_event("Aplicando senha nos perfis do RustDesk");
|
||||
|
|
@ -185,21 +185,21 @@ pub fn ensure_rustdesk(
|
|||
log_event("Senha e flags de segurança gravadas em todos os perfis do RustDesk");
|
||||
log_password_replication(&password);
|
||||
}
|
||||
Err(error) => log_event(&format!("Falha ao persistir senha nos perfis: {error}")),
|
||||
Err(error) => log_event(format!("Falha ao persistir senha nos perfis: {error}")),
|
||||
}
|
||||
|
||||
match propagate_password_profile() {
|
||||
Ok(_) => log_event("Perfil base propagado para ProgramData e perfis de serviço"),
|
||||
Err(error) => log_event(&format!("Falha ao copiar perfil de senha: {error}")),
|
||||
Err(error) => log_event(format!("Falha ao copiar perfil de senha: {error}")),
|
||||
}
|
||||
|
||||
match replicate_password_artifacts() {
|
||||
Ok(_) => log_event("Artefatos de senha replicados para o serviço do RustDesk"),
|
||||
Err(error) => log_event(&format!("Falha ao replicar artefatos de senha: {error}")),
|
||||
Err(error) => log_event(format!("Falha ao replicar artefatos de senha: {error}")),
|
||||
}
|
||||
|
||||
if let Err(error) = enforce_security_flags() {
|
||||
log_event(&format!("Falha ao reforçar configuração de senha permanente: {error}"));
|
||||
log_event(format!("Falha ao reforçar configuração de senha permanente: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ pub fn ensure_rustdesk(
|
|||
// Isso garante que reinstalar o Raven nao muda o ID do RustDesk
|
||||
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
|
||||
if !freshly_installed {
|
||||
log_event(&format!("Reutilizando ID existente do RustDesk: {}", existing_id));
|
||||
log_event(format!("Reutilizando ID existente do RustDesk: {}", existing_id));
|
||||
Some(existing_id.clone())
|
||||
} else {
|
||||
// Instalacao fresca - define novo ID baseado no machine_id
|
||||
|
|
@ -219,7 +219,7 @@ pub fn ensure_rustdesk(
|
|||
};
|
||||
|
||||
if let Err(error) = ensure_service_running(&exe_path) {
|
||||
log_event(&format!("Falha ao reiniciar serviço do RustDesk: {error}"));
|
||||
log_event(format!("Falha ao reiniciar serviço do RustDesk: {error}"));
|
||||
} else {
|
||||
log_event("Serviço RustDesk reiniciado/run ativo");
|
||||
}
|
||||
|
|
@ -227,10 +227,10 @@ pub fn ensure_rustdesk(
|
|||
let reported_id = match query_id_with_retries(&exe_path, 5) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
log_event(&format!("Falha ao obter ID após múltiplas tentativas: {error}"));
|
||||
log_event(format!("Falha ao obter ID após múltiplas tentativas: {error}"));
|
||||
match read_remote_id_from_profiles().or_else(|| custom_id.clone()) {
|
||||
Some(value) => {
|
||||
log_event(&format!("ID obtido via arquivos de perfil: {value}"));
|
||||
log_event(format!("ID obtido via arquivos de perfil: {value}"));
|
||||
value
|
||||
}
|
||||
None => return Err(error),
|
||||
|
|
@ -242,7 +242,7 @@ pub fn ensure_rustdesk(
|
|||
|
||||
if let Some(expected) = custom_id.as_ref() {
|
||||
if expected != &reported_id {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"ID retornado difere do determinístico ({expected}) -> reaplicando ID determinístico"
|
||||
));
|
||||
|
||||
|
|
@ -252,25 +252,25 @@ pub fn ensure_rustdesk(
|
|||
Ok(_) => match query_id_with_retries(&exe_path, 3) {
|
||||
Ok(rechecked) => {
|
||||
if &rechecked == expected {
|
||||
log_event(&format!("ID determinístico aplicado com sucesso: {rechecked}"));
|
||||
log_event(format!("ID determinístico aplicado com sucesso: {rechecked}"));
|
||||
final_id = rechecked;
|
||||
enforced = true;
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"ID ainda difere após reaplicação (esperado {expected}, reportado {rechecked}); usando ID reportado"
|
||||
));
|
||||
final_id = rechecked;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao consultar ID após reaplicação: {error}; usando ID reportado ({reported_id})"
|
||||
));
|
||||
final_id = reported_id.clone();
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao reaplicar ID determinístico ({expected}): {error}; usando ID reportado ({reported_id})"
|
||||
));
|
||||
final_id = reported_id.clone();
|
||||
|
|
@ -308,7 +308,7 @@ pub fn ensure_rustdesk(
|
|||
"lastError": serde_json::Value::Null
|
||||
});
|
||||
if let Err(error) = upsert_machine_store_value("rustdesk", rustdesk_data) {
|
||||
log_event(&format!("Aviso: falha ao salvar dados do RustDesk no store: {error}"));
|
||||
log_event(format!("Aviso: falha ao salvar dados do RustDesk no store: {error}"));
|
||||
} else {
|
||||
log_event("Dados do RustDesk salvos no machine-agent.json");
|
||||
}
|
||||
|
|
@ -316,7 +316,7 @@ pub fn ensure_rustdesk(
|
|||
// Sincroniza com o backend imediatamente apos provisionar
|
||||
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
|
||||
if let Err(error) = sync_remote_access_with_backend(&result) {
|
||||
log_event(&format!("Aviso: falha ao sincronizar com backend: {error}"));
|
||||
log_event(format!("Aviso: falha ao sincronizar com backend: {error}"));
|
||||
} else {
|
||||
log_event("Acesso remoto sincronizado com backend");
|
||||
// Atualiza lastSyncedAt no store
|
||||
|
|
@ -330,13 +330,13 @@ pub fn ensure_rustdesk(
|
|||
"lastError": serde_json::Value::Null
|
||||
});
|
||||
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
|
||||
log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
|
||||
log_event(format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
|
||||
} else {
|
||||
log_event("lastSyncedAt atualizado com sucesso");
|
||||
}
|
||||
}
|
||||
|
||||
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
|
||||
log_event(format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
|
@ -403,7 +403,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
|||
let config_contents = build_config_contents();
|
||||
let main_path = program_data_config_dir().join("RustDesk2.toml");
|
||||
write_file(&main_path, &config_contents)?;
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Config principal gravada em {}",
|
||||
main_path.display()
|
||||
));
|
||||
|
|
@ -412,7 +412,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
|||
for service_dir in service_profile_dirs() {
|
||||
let service_profile = service_dir.join("RustDesk2.toml");
|
||||
if let Err(error) = write_file(&service_profile, &config_contents) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao gravar config no perfil do serviço ({}): {error}",
|
||||
service_profile.display()
|
||||
));
|
||||
|
|
@ -421,7 +421,7 @@ fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
|||
|
||||
if let Some(appdata_path) = user_appdata_config_path("RustDesk2.toml") {
|
||||
if let Err(error) = write_file(&appdata_path, &config_contents) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao atualizar config no AppData do usuário: {error}"
|
||||
));
|
||||
}
|
||||
|
|
@ -516,7 +516,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
|
|||
ensure_service_installed(exe_path)?;
|
||||
|
||||
if let Err(error) = configure_service_startup() {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Aviso: não foi possível reforçar autostart/recuperação do serviço RustDesk: {error}"
|
||||
));
|
||||
}
|
||||
|
|
@ -553,7 +553,7 @@ fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
|
|||
let _ = run_with_args(exe_path, &["--install-service"]);
|
||||
let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]);
|
||||
if let Err(error) = start_sequence() {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao subir o serviço RustDesk mesmo após reinstalação: {error}"
|
||||
));
|
||||
}
|
||||
|
|
@ -631,8 +631,8 @@ fn remove_rustdesk_autorun_artifacts() {
|
|||
for path in startup_paths {
|
||||
if path.exists() {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(_) => log_event(&format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
|
||||
Err(error) => log_event(&format!(
|
||||
Ok(_) => log_event(format!("Atalho de inicialização do RustDesk removido: {}", path.display())),
|
||||
Err(error) => log_event(format!(
|
||||
"Falha ao remover atalho de inicialização do RustDesk ({}): {}",
|
||||
path.display(),
|
||||
error
|
||||
|
|
@ -650,7 +650,7 @@ fn remove_rustdesk_autorun_artifacts() {
|
|||
.status();
|
||||
if let Ok(code) = status {
|
||||
if code.success() {
|
||||
log_event(&format!("Entrada de auto-run RustDesk removida de {}", reg_path));
|
||||
log_event(format!("Entrada de auto-run RustDesk removida de {}", reg_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -658,7 +658,7 @@ fn remove_rustdesk_autorun_artifacts() {
|
|||
|
||||
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
|
||||
if let Err(error) = try_stop_service() {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Não foi possível parar o serviço RustDesk antes da sincronização: {error}"
|
||||
));
|
||||
}
|
||||
|
|
@ -774,12 +774,12 @@ fn ensure_remote_id_files(id: &str) {
|
|||
for dir in remote_id_directories() {
|
||||
let path = dir.join("RustDesk_local.toml");
|
||||
match write_remote_id_value(&path, id) {
|
||||
Ok(_) => log_event(&format!(
|
||||
Ok(_) => log_event(format!(
|
||||
"remote_id atualizado para {} em {}",
|
||||
id,
|
||||
path.display()
|
||||
)),
|
||||
Err(error) => log_event(&format!(
|
||||
Err(error) => log_event(format!(
|
||||
"Falha ao atualizar remote_id em {}: {error}",
|
||||
path.display()
|
||||
)),
|
||||
|
|
@ -821,7 +821,7 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
|
|||
if let Err(error) = write_toml_kv(&password_path, "password", secret) {
|
||||
errors.push(format!("{} -> {}", password_path.display(), error));
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Senha escrita via fallback em {}",
|
||||
password_path.display()
|
||||
));
|
||||
|
|
@ -829,12 +829,12 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
|
|||
|
||||
let local_path = dir.join("RustDesk_local.toml");
|
||||
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao ajustar verification-method em {}: {error}",
|
||||
local_path.display()
|
||||
));
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"verification-method atualizado para {} em {}",
|
||||
SECURITY_VERIFICATION_VALUE,
|
||||
local_path.display()
|
||||
|
|
@ -843,19 +843,19 @@ fn ensure_password_files(secret: &str) -> Result<(), String> {
|
|||
|
||||
let rustdesk2_path = dir.join("RustDesk2.toml");
|
||||
if let Err(error) = enforce_security_in_rustdesk2(&rustdesk2_path) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao ajustar flags no RustDesk2.toml em {}: {error}",
|
||||
rustdesk2_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao ajustar approve-mode em {}: {error}",
|
||||
local_path.display()
|
||||
));
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"approve-mode atualizado para {} em {}",
|
||||
SECURITY_APPROVE_MODE_VALUE,
|
||||
local_path.display()
|
||||
|
|
@ -877,7 +877,7 @@ fn enforce_security_flags() -> Result<(), String> {
|
|||
if let Err(error) = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE) {
|
||||
errors.push(format!("{} -> {}", local_path.display(), error));
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"verification-method atualizado para {} em {}",
|
||||
SECURITY_VERIFICATION_VALUE,
|
||||
local_path.display()
|
||||
|
|
@ -887,7 +887,7 @@ fn enforce_security_flags() -> Result<(), String> {
|
|||
if let Err(error) = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE) {
|
||||
errors.push(format!("{} -> {}", local_path.display(), error));
|
||||
} else {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"approve-mode atualizado para {} em {}",
|
||||
SECURITY_APPROVE_MODE_VALUE,
|
||||
local_path.display()
|
||||
|
|
@ -921,7 +921,7 @@ fn propagate_password_profile() -> io::Result<bool> {
|
|||
if !src_path.exists() {
|
||||
continue;
|
||||
}
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Copiando {} para ProgramData/serviços",
|
||||
src_path.display()
|
||||
));
|
||||
|
|
@ -929,7 +929,7 @@ fn propagate_password_profile() -> io::Result<bool> {
|
|||
for dest_root in propagation_destinations() {
|
||||
let target_path = dest_root.join(filename);
|
||||
copy_overwrite(&src_path, &target_path)?;
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"{} propagado para {}",
|
||||
filename,
|
||||
target_path.display()
|
||||
|
|
@ -969,7 +969,7 @@ fn replicate_password_artifacts() -> io::Result<()> {
|
|||
|
||||
let target_path = dest.join(name);
|
||||
copy_overwrite(&source_path, &target_path)?;
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Artefato de senha {name} replicado para {}",
|
||||
target_path.display()
|
||||
));
|
||||
|
|
@ -981,13 +981,11 @@ fn replicate_password_artifacts() -> io::Result<()> {
|
|||
|
||||
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
let mut cleaned_any = false;
|
||||
|
||||
for dir in remote_id_directories() {
|
||||
match purge_config_dir(&dir) {
|
||||
Ok(true) => {
|
||||
cleaned_any = true;
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Perfis antigos removidos em {}",
|
||||
dir.display()
|
||||
));
|
||||
|
|
@ -997,9 +995,7 @@ fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
if cleaned_any {
|
||||
Ok(())
|
||||
} else if errors.is_empty() {
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join(" | "))
|
||||
|
|
@ -1030,6 +1026,7 @@ fn purge_config_dir(dir: &Path) -> Result<bool, io::Error> {
|
|||
Ok(removed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn run_powershell_elevated(script: &str) -> Result<(), String> {
|
||||
let temp_dir = env::temp_dir();
|
||||
let payload = temp_dir.join("raven_payload.ps1");
|
||||
|
|
@ -1077,6 +1074,7 @@ exit $process.ExitCode
|
|||
Err(format!("elevated ps exit {:?}", status.code()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn fix_profile_acl(target: &Path) -> Result<(), String> {
|
||||
let target_str = target.display().to_string();
|
||||
let transcript = env::temp_dir().join("raven_acl_ps.log");
|
||||
|
|
@ -1111,7 +1109,7 @@ try {{
|
|||
let result = run_powershell_elevated(&script);
|
||||
if result.is_err() {
|
||||
if let Ok(content) = fs::read_to_string(&transcript) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"ACL transcript para {}:\n{}",
|
||||
target.display(), content
|
||||
));
|
||||
|
|
@ -1122,6 +1120,9 @@ try {{
|
|||
}
|
||||
|
||||
fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
|
||||
// Verificamos se os diretorios de perfil sao graváveis
|
||||
// Se nao forem, apenas logamos aviso - o Raven Service deve lidar com isso
|
||||
// Nao usamos elevacao para evitar UAC adicional
|
||||
let mut blocked_dirs = Vec::new();
|
||||
for dir in service_profile_dirs() {
|
||||
if !can_write_dir(&dir) {
|
||||
|
|
@ -1133,53 +1134,46 @@ fn ensure_service_profiles_writable_preflight() -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
if has_acl_unlock_flag() {
|
||||
log_event("Perfis do serviço voltaram a bloquear escrita; reaplicando correção de ACL");
|
||||
} else {
|
||||
log_event("Executando ajuste inicial de ACL dos perfis do serviço (requer UAC)");
|
||||
}
|
||||
// Apenas logamos aviso - o serviço RavenService deve lidar com permissões
|
||||
log_event(format!(
|
||||
"Aviso: alguns perfis de serviço não são graváveis: {:?}. O Raven Service deve configurar permissões.",
|
||||
blocked_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()
|
||||
));
|
||||
|
||||
let mut last_error: Option<String> = None;
|
||||
for dir in blocked_dirs.iter() {
|
||||
log_event(&format!(
|
||||
"Tentando corrigir ACL via UAC (preflight) em {}...",
|
||||
dir.display()
|
||||
));
|
||||
if let Err(error) = fix_profile_acl(dir) {
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
if can_write_dir(dir) {
|
||||
log_event(&format!(
|
||||
"ACL ajustada com sucesso em {}",
|
||||
dir.display()
|
||||
));
|
||||
} else {
|
||||
last_error = Some(format!(
|
||||
"continua sem permissão para {} mesmo após preflight",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if blocked_dirs.iter().all(|dir| can_write_dir(dir)) {
|
||||
mark_acl_unlock_flag();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(last_error.unwrap_or_else(|| "nenhum perfil de serviço acessível".into()))
|
||||
}
|
||||
// Retornamos Ok para não bloquear o fluxo
|
||||
// O Raven Service, rodando como LocalSystem, pode gravar nesses diretórios
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_service_elevated() -> Result<(), String> {
|
||||
let script = r#"
|
||||
$ErrorActionPreference='Stop'
|
||||
$service = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
|
||||
if ($service -and $service.Status -ne 'Stopped') {
|
||||
Stop-Service -Name 'RustDesk' -Force -ErrorAction Stop
|
||||
$service.WaitForStatus('Stopped','00:00:10')
|
||||
}
|
||||
"#;
|
||||
run_powershell_elevated(script)
|
||||
// Tentamos parar o serviço RustDesk sem elevação
|
||||
// Se falhar, apenas logamos aviso - o Raven Service pode lidar com isso
|
||||
// Não usamos elevação para evitar UAC adicional
|
||||
let output = Command::new("sc")
|
||||
.args(["stop", "RustDesk"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
// Aguarda um pouco para o serviço parar
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
log_event(format!(
|
||||
"Aviso: não foi possível parar o serviço RustDesk sem elevação: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
// Retornamos Ok para não bloquear - o serviço pode estar já parado
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log_event(format!("Aviso: falha ao executar sc stop RustDesk: {e}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_write_dir(dir: &Path) -> bool {
|
||||
|
|
@ -1339,21 +1333,21 @@ fn log_password_replication(secret: &str) {
|
|||
fn log_password_match(path: &Path, secret: &str) {
|
||||
match read_password_from_file(path) {
|
||||
Some(value) if value == secret => {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Senha confirmada em {} ({})",
|
||||
path.display(),
|
||||
mask_secret(&value)
|
||||
));
|
||||
}
|
||||
Some(value) => {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Aviso: senha divergente ({}) em {}",
|
||||
mask_secret(&value),
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
None => {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Aviso: chave 'password' não encontrada em {}",
|
||||
path.display()
|
||||
));
|
||||
|
|
@ -1469,21 +1463,24 @@ fn write_machine_store_object(map: JsonMap<String, JsonValue>) -> Result<(), Str
|
|||
}
|
||||
|
||||
fn upsert_machine_store_value(key: &str, value: JsonValue) -> Result<(), String> {
|
||||
let mut map = read_machine_store_object().unwrap_or_else(JsonMap::new);
|
||||
let mut map = read_machine_store_object().unwrap_or_default();
|
||||
map.insert(key.to_string(), value);
|
||||
write_machine_store_object(map)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn machine_store_key_exists(key: &str) -> bool {
|
||||
read_machine_store_object()
|
||||
.map(|map| map.contains_key(key))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn acl_flag_file_path() -> Option<PathBuf> {
|
||||
raven_appdata_root().map(|dir| dir.join(ACL_FLAG_FILENAME))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn has_acl_unlock_flag() -> bool {
|
||||
if let Some(flag) = acl_flag_file_path() {
|
||||
if flag.exists() {
|
||||
|
|
@ -1493,6 +1490,7 @@ fn has_acl_unlock_flag() -> bool {
|
|||
machine_store_key_exists(RUSTDESK_ACL_STORE_KEY)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn mark_acl_unlock_flag() {
|
||||
let timestamp = Utc::now().timestamp_millis();
|
||||
if let Some(flag_path) = acl_flag_file_path() {
|
||||
|
|
@ -1500,7 +1498,7 @@ fn mark_acl_unlock_flag() {
|
|||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(error) = fs::write(&flag_path, timestamp.to_string()) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao gravar flag de ACL em {}: {error}",
|
||||
flag_path.display()
|
||||
));
|
||||
|
|
@ -1508,7 +1506,7 @@ fn mark_acl_unlock_flag() {
|
|||
}
|
||||
|
||||
if let Err(error) = upsert_machine_store_value(RUSTDESK_ACL_STORE_KEY, JsonValue::from(timestamp)) {
|
||||
log_event(&format!(
|
||||
log_event(format!(
|
||||
"Falha ao registrar flag de ACL no machine-agent: {error}"
|
||||
));
|
||||
}
|
||||
|
|
@ -1547,7 +1545,7 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
|
|||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("https://tickets.esdrasrenan.com.br");
|
||||
|
||||
log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
|
||||
log_event(format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
|
||||
|
||||
// Monta payload conforme schema esperado pelo backend
|
||||
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
|
||||
|
|
@ -1575,13 +1573,13 @@ fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -
|
|||
.send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
log_event(&format!("Sync com backend OK: status {}", response.status()));
|
||||
log_event(format!("Sync com backend OK: status {}", response.status()));
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
||||
log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview));
|
||||
log_event(format!("Sync com backend falhou: {} - {}", status, body_preview));
|
||||
Err(RustdeskError::CommandFailed {
|
||||
command: "sync_remote_access".to_string(),
|
||||
status: Some(status.as_u16() as i32)
|
||||
|
|
|
|||
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,22 +93,10 @@ mod windows_impl {
|
|||
applied_at: Some(now),
|
||||
}),
|
||||
Err(err) => {
|
||||
// Tenta elevação se faltou permissão
|
||||
// Se faltou permissão, retorna erro - o serviço deve ser usado
|
||||
// Não fazemos elevação aqui para evitar UAC adicional
|
||||
if is_permission_error(&err) {
|
||||
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
|
||||
return Err(elevated_err);
|
||||
}
|
||||
// Revalida a policy após elevação
|
||||
let current = get_current_policy()?;
|
||||
if current != policy {
|
||||
return Err(UsbControlError::PermissionDenied);
|
||||
}
|
||||
return Ok(UsbPolicyResult {
|
||||
success: true,
|
||||
policy: policy.as_str().to_string(),
|
||||
error: None,
|
||||
applied_at: Some(now),
|
||||
});
|
||||
return Err(UsbControlError::PermissionDenied);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
|
|
@ -219,10 +207,8 @@ mod windows_impl {
|
|||
|
||||
key.set_value("WriteProtect", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
} else {
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||
let _ = key.set_value("WriteProtect", &0u32);
|
||||
}
|
||||
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||
let _ = key.set_value("WriteProtect", &0u32);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -269,6 +255,7 @@ mod windows_impl {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||
// Cria script temporário para aplicar as chaves via PowerShell elevado
|
||||
let temp_dir = std::env::temp_dir();
|
||||
|
|
@ -321,7 +308,7 @@ try {{
|
|||
policy = policy_str
|
||||
);
|
||||
|
||||
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?;
|
||||
fs::write(&script_path, script).map_err(UsbControlError::Io)?;
|
||||
|
||||
// Start-Process com RunAs para acionar UAC
|
||||
let arg = format!(
|
||||
|
|
@ -333,7 +320,7 @@ try {{
|
|||
.arg("-Command")
|
||||
.arg(arg)
|
||||
.status()
|
||||
.map_err(|e| UsbControlError::Io(e))?;
|
||||
.map_err(UsbControlError::Io)?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(UsbControlError::PermissionDenied);
|
||||
|
|
@ -362,7 +349,7 @@ try {{
|
|||
.args(["/target:computer", "/force"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| UsbControlError::Io(e))?;
|
||||
.map_err(UsbControlError::Io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Nao e critico se falhar, apenas log
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@
|
|||
"icons/icon.png",
|
||||
"icons/Raven.png"
|
||||
],
|
||||
"resources": {
|
||||
"../service/target/release/raven-service.exe": "raven-service.exe"
|
||||
},
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"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 { ChatHubWidget } from "./ChatHubWidget"
|
||||
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function ChatAppContent() {
|
||||
const { client, isReady, error } = useConvexMachine()
|
||||
|
||||
export function ChatApp() {
|
||||
// Obter ticketId e ticketRef da URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const ticketId = params.get("ticketId")
|
||||
const ticketRef = params.get("ticketRef")
|
||||
const isHub = params.get("hub") === "true"
|
||||
|
||||
// Aguardar cliente Convex estar pronto
|
||||
if (!isReady || !client) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||
<span className="text-sm font-medium">Erro: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p>
|
||||
<div 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>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||
// 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 { ChatHubWidget }
|
||||
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 (
|
||||
<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 flex-col items-center gap-3 text-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
|
||||
<ShieldAlert className="size-4" /> Acesso bloqueado
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativado</h1>
|
||||
<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.
|
||||
</p>
|
||||
{companyName ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700">
|
||||
{companyName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[520px] space-y-4">
|
||||
|
|
@ -29,12 +42,25 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="mailto:suporte@rever.com.br"
|
||||
className="mx-auto inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||
>
|
||||
<Mail className="size-4" /> Falar com o suporte
|
||||
</a>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a
|
||||
href="mailto:suporte@rever.com.br"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||
>
|
||||
<Mail className="size-4" /> Falar com o suporte
|
||||
</a>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${isRetrying ? "animate-spin" : ""}`} />
|
||||
{isRetrying ? "Verificando..." : "Verificar novamente"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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 { appLocalDataDir, join } from "@tauri-apps/api/path"
|
||||
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
||||
import { ConvexReactClient } from "convex/react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
||||
import { cn } from "./lib/utils"
|
||||
import { ChatApp } from "./chat"
|
||||
import { DeactivationScreen } from "./components/DeactivationScreen"
|
||||
import { MachineStateMonitor } from "./components/MachineStateMonitor"
|
||||
import { api } from "./convex/_generated/api"
|
||||
import type { Id } from "./convex/_generated/dataModel"
|
||||
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
|
||||
|
||||
// URL do Convex para subscription em tempo real
|
||||
const CONVEX_URL = import.meta.env.MODE === "production"
|
||||
? "https://convex.esdrasrenan.com.br"
|
||||
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
|
||||
|
||||
type MachineOs = {
|
||||
name: string
|
||||
version?: string | null
|
||||
|
|
@ -304,7 +313,7 @@ function App() {
|
|||
const [token, setToken] = useState<string | null>(null)
|
||||
const [config, setConfig] = useState<AgentConfig | 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 [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
|
@ -321,6 +330,9 @@ function App() {
|
|||
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
|
||||
const lastHealAtRef = useRef(0)
|
||||
|
||||
// Cliente Convex para monitoramento em tempo real do estado da maquina
|
||||
const [convexClient, setConvexClient] = useState<ConvexReactClient | null>(null)
|
||||
|
||||
const [provisioningCode, setProvisioningCode] = useState("")
|
||||
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
|
||||
const [companyName, setCompanyName] = useState("")
|
||||
|
|
@ -410,8 +422,15 @@ function App() {
|
|||
status: "online",
|
||||
intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300,
|
||||
})
|
||||
// Iniciar sistema de chat apos o agente
|
||||
await invoke("start_chat_polling", {
|
||||
baseUrl: apiBaseUrl,
|
||||
convexUrl: "https://convex.esdrasrenan.com.br",
|
||||
token: data.machineToken,
|
||||
})
|
||||
logDesktop("chat:started")
|
||||
} catch (err) {
|
||||
console.error("Falha ao reiniciar heartbeat", err)
|
||||
console.error("Falha ao reiniciar heartbeat/chat", err)
|
||||
}
|
||||
|
||||
return nextConfig
|
||||
|
|
@ -586,8 +605,15 @@ function App() {
|
|||
status: "online",
|
||||
intervalSeconds: 300,
|
||||
})
|
||||
// Iniciar sistema de chat apos o agente
|
||||
await invoke("start_chat_polling", {
|
||||
baseUrl: apiBaseUrl,
|
||||
convexUrl: "https://convex.esdrasrenan.com.br",
|
||||
token,
|
||||
})
|
||||
logDesktop("chat:started:validation")
|
||||
} catch (err) {
|
||||
console.error("Falha ao iniciar heartbeat em segundo plano", err)
|
||||
console.error("Falha ao iniciar heartbeat/chat em segundo plano", err)
|
||||
}
|
||||
const payload = await res.clone().json().catch(() => null)
|
||||
if (payload && typeof payload === "object" && "machine" in payload) {
|
||||
|
|
@ -679,6 +705,88 @@ useEffect(() => {
|
|||
rustdeskInfoRef.current = rustdeskInfo
|
||||
}, [rustdeskInfo])
|
||||
|
||||
// Cria/destrói cliente Convex quando o token muda
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (convexClient) {
|
||||
convexClient.close()
|
||||
setConvexClient(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Cria novo cliente Convex para monitoramento em tempo real
|
||||
const client = new ConvexReactClient(CONVEX_URL, {
|
||||
unsavedChangesWarning: false,
|
||||
})
|
||||
setConvexClient(client)
|
||||
|
||||
return () => {
|
||||
client.close()
|
||||
}
|
||||
}, [token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Callbacks para quando a máquina for desativada, resetada ou reativada
|
||||
const handleMachineDeactivated = useCallback(() => {
|
||||
console.log("[App] Máquina foi desativada - mostrando tela de bloqueio")
|
||||
setIsMachineActive(false)
|
||||
}, [])
|
||||
|
||||
const handleMachineReactivated = useCallback(() => {
|
||||
console.log("[App] Máquina foi reativada - liberando acesso")
|
||||
setIsMachineActive(true)
|
||||
}, [])
|
||||
|
||||
// Callback para o botão "Verificar novamente" na tela de desativação
|
||||
// Usa o convexClient diretamente para fazer uma query manual
|
||||
const handleRetryCheck = useCallback(async () => {
|
||||
if (!convexClient || !config?.machineId) return
|
||||
console.log("[App] Verificando estado da máquina manualmente...")
|
||||
try {
|
||||
const state = await convexClient.query(api.machines.getMachineState, {
|
||||
machineId: config.machineId as Id<"machines">,
|
||||
})
|
||||
console.log("[App] Estado da máquina:", state)
|
||||
if (state?.isActive) {
|
||||
console.log("[App] Máquina ativa - liberando acesso")
|
||||
setIsMachineActive(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[App] Erro ao verificar estado:", err)
|
||||
}
|
||||
}, [convexClient, config?.machineId])
|
||||
|
||||
const handleTokenRevoked = useCallback(async () => {
|
||||
console.log("[App] Token foi revogado - voltando para tela de registro")
|
||||
if (store) {
|
||||
try {
|
||||
await store.delete("token")
|
||||
await store.delete("config")
|
||||
await store.save()
|
||||
} catch (err) {
|
||||
console.error("Falha ao limpar store", err)
|
||||
}
|
||||
}
|
||||
tokenVerifiedRef.current = false
|
||||
autoLaunchRef.current = false
|
||||
setToken(null)
|
||||
setConfig(null)
|
||||
setStatus(null)
|
||||
setIsMachineActive(true)
|
||||
setIsLaunchingSystem(false)
|
||||
// Limpa campos de input para novo registro
|
||||
setProvisioningCode("")
|
||||
setCollabEmail("")
|
||||
setCollabName("")
|
||||
setValidatedCompany(null)
|
||||
setCodeStatus(null)
|
||||
setCompanyName("")
|
||||
setError("Este dispositivo foi resetado. Informe o código de provisionamento para reconectar.")
|
||||
// Força navegar de volta para a página inicial do app Tauri (não do servidor web)
|
||||
// URL do app Tauri em produção é http://tauri.localhost/, em dev é http://localhost:1420/
|
||||
const appUrl = import.meta.env.MODE === "production" ? "http://tauri.localhost/" : "http://localhost:1420/"
|
||||
window.location.href = appUrl
|
||||
}, [store])
|
||||
|
||||
useEffect(() => {
|
||||
if (!store || !config) return
|
||||
|
|
@ -1249,6 +1357,10 @@ const resolvedAppUrl = useMemo(() => {
|
|||
|
||||
const openSystem = useCallback(async () => {
|
||||
if (!token) return
|
||||
if (!isMachineActive) {
|
||||
setIsLaunchingSystem(false)
|
||||
return
|
||||
}
|
||||
setIsLaunchingSystem(true)
|
||||
|
||||
// Recarrega store do disco para pegar dados que o Rust salvou diretamente
|
||||
|
|
@ -1308,7 +1420,6 @@ const resolvedAppUrl = useMemo(() => {
|
|||
setError(null)
|
||||
}
|
||||
if (!currentActive) {
|
||||
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
|
||||
setIsLaunchingSystem(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -1316,14 +1427,8 @@ const resolvedAppUrl = useMemo(() => {
|
|||
}
|
||||
} else {
|
||||
if (res.status === 423) {
|
||||
const payload = await res.clone().json().catch(() => null)
|
||||
const message =
|
||||
payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string"
|
||||
? ((payload as { error?: string }).error ?? "").trim()
|
||||
: ""
|
||||
setIsMachineActive(false)
|
||||
setIsLaunchingSystem(false)
|
||||
setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
|
||||
return
|
||||
}
|
||||
// Se sessão falhar, tenta identificar token inválido/expirado
|
||||
|
|
@ -1373,7 +1478,7 @@ const resolvedAppUrl = useMemo(() => {
|
|||
const url = `${safeAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
logDesktop("openSystem:redirect", { url: url.replace(/token=[^&]+/, "token=***") })
|
||||
window.location.href = url
|
||||
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store])
|
||||
}, [token, config?.accessRole, config?.machineId, resolvedAppUrl, store, isMachineActive])
|
||||
|
||||
async function reprovision() {
|
||||
if (!store) return
|
||||
|
|
@ -1478,17 +1583,28 @@ const resolvedAppUrl = useMemo(() => {
|
|||
if (!token) return
|
||||
if (autoLaunchRef.current) return
|
||||
if (!tokenVerifiedRef.current) return
|
||||
if (!isMachineActive) return // Não redireciona se a máquina estiver desativada
|
||||
autoLaunchRef.current = true
|
||||
setIsLaunchingSystem(true)
|
||||
openSystem()
|
||||
}, [token, status, config?.accessRole, openSystem, tokenValidationTick])
|
||||
}, [token, status, config?.accessRole, openSystem, tokenValidationTick, isMachineActive])
|
||||
|
||||
// Quando há token persistido (dispositivo já provisionado) e ainda não
|
||||
// disparamos o auto-launch, exibimos diretamente a tela de loading da
|
||||
// plataforma para evitar piscar o card de resumo/inventário.
|
||||
if ((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) {
|
||||
// IMPORTANTE: Sempre renderiza o MachineStateMonitor para detectar desativação em tempo real
|
||||
if (((token && !autoLaunchRef.current) || (isLaunchingSystem && token)) && isMachineActive) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
||||
{/* Monitor de estado da máquina - deve rodar mesmo durante loading */}
|
||||
{token && config?.machineId && convexClient && (
|
||||
<MachineStateMonitor
|
||||
client={convexClient}
|
||||
machineId={config.machineId}
|
||||
onDeactivated={handleMachineDeactivated}
|
||||
onTokenRevoked={handleTokenRevoked}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
||||
<Loader2 className="size-6 animate-spin text-neutral-700" />
|
||||
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever…</p>
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
||||
{token && !isMachineActive ? (
|
||||
<DeactivationScreen companyName={companyName} />
|
||||
) : (
|
||||
{/* Monitor de estado da maquina em tempo real via Convex */}
|
||||
{machineMonitor}
|
||||
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-4 text-center">
|
||||
<img
|
||||
|
|
@ -1510,16 +1646,23 @@ const resolvedAppUrl = useMemo(() => {
|
|||
alt="Logotipo Raven"
|
||||
width={160}
|
||||
height={160}
|
||||
className="h-14 w-auto md:h-16"
|
||||
className="h-16 w-auto md:h-20"
|
||||
onError={() => {
|
||||
if (logoFallbackRef.current) return
|
||||
logoFallbackRef.current = true
|
||||
setLogoSrc(`${appUrl}/raven.png`)
|
||||
setLogoSrc(`${appUrl}/logo-raven.png`)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-neutral-500">Raven</span>
|
||||
<span className="text-2xl font-semibold text-neutral-900">Sistema de chamados</span>
|
||||
<span className="text-lg font-semibold text-neutral-900">Raven</span>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
||||
Plataforma de
|
||||
</span>
|
||||
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
||||
Chamados
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1723,8 +1866,6 @@ const resolvedAppUrl = useMemo(() => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@
|
|||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@convex/_generated/*": ["./src/convex/_generated/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
|
|
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
|
|||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
// Usar arquivos _generated locais para evitar problemas de type-check
|
||||
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
|
|
|
|||
58
bun.lock
58
bun.lock
|
|
@ -21,6 +21,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
@ -114,6 +115,7 @@
|
|||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"convex": "^1.31.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -512,6 +514,8 @@
|
|||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
|
@ -2332,6 +2336,8 @@
|
|||
|
||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
|
||||
|
||||
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
|
||||
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
|
@ -2466,6 +2472,8 @@
|
|||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
|
@ -2597,5 +2605,55 @@
|
|||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "./automationsEngine"
|
||||
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
||||
import { renderAutomationEmailHtml, type AutomationEmailProps } from "./reactEmail"
|
||||
import type { AutomationEmailProps } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
import { applyChecklistTemplateToItems, type TicketChecklistItem } from "./ticketChecklist"
|
||||
|
||||
|
|
@ -988,19 +988,38 @@ async function applyActions(
|
|||
ctaLabel,
|
||||
ctaUrl,
|
||||
}
|
||||
const html = await renderAutomationEmailHtml(emailProps)
|
||||
|
||||
await schedulerRunAfter(1, api.ticketNotifications.sendAutomationEmail, {
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
emailProps: {
|
||||
title: emailProps.title,
|
||||
message: emailProps.message,
|
||||
ticket: {
|
||||
reference: emailProps.ticket.reference,
|
||||
subject: emailProps.ticket.subject,
|
||||
status: emailProps.ticket.status ?? null,
|
||||
priority: emailProps.ticket.priority ?? null,
|
||||
companyName: emailProps.ticket.companyName ?? null,
|
||||
requesterName: emailProps.ticket.requesterName ?? null,
|
||||
assigneeName: emailProps.ticket.assigneeName ?? null,
|
||||
},
|
||||
ctaLabel: emailProps.ctaLabel,
|
||||
ctaUrl: emailProps.ctaUrl,
|
||||
},
|
||||
})
|
||||
|
||||
applied.push({
|
||||
type: "SEND_EMAIL",
|
||||
details: {
|
||||
recipients: to,
|
||||
toCount: to.length,
|
||||
subject,
|
||||
messagePreview: message.length > 100 ? `${message.slice(0, 100)}...` : message,
|
||||
ctaTarget: effectiveTarget,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
scheduledAt: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,17 +14,37 @@ function normalizeTemplateDescription(input: string | null | undefined) {
|
|||
return text.length > 0 ? text : null
|
||||
}
|
||||
|
||||
type ChecklistItemType = "checkbox" | "question"
|
||||
|
||||
type RawTemplateItem = {
|
||||
id?: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: string
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
type NormalizedTemplateItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: ChecklistItemType
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function normalizeTemplateItems(
|
||||
raw: Array<{ id?: string; text: string; required?: boolean }>,
|
||||
raw: RawTemplateItem[],
|
||||
options: { generateId?: () => string }
|
||||
) {
|
||||
): NormalizedTemplateItem[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
throw new ConvexError("Adicione pelo menos um item no checklist.")
|
||||
}
|
||||
|
||||
const generateId = options.generateId ?? (() => crypto.randomUUID())
|
||||
const seen = new Set<string>()
|
||||
const items: Array<{ id: string; text: string; required?: boolean }> = []
|
||||
const items: NormalizedTemplateItem[] = []
|
||||
|
||||
for (const entry of raw) {
|
||||
const id = String(entry.id ?? "").trim() || generateId()
|
||||
|
|
@ -41,8 +61,25 @@ function normalizeTemplateItems(
|
|||
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
|
||||
}
|
||||
|
||||
const description = entry.description?.trim() || undefined
|
||||
const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox"
|
||||
const itemOptions = itemType === "question" && Array.isArray(entry.options)
|
||||
? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0)
|
||||
: undefined
|
||||
|
||||
if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) {
|
||||
throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opções.`)
|
||||
}
|
||||
|
||||
const required = typeof entry.required === "boolean" ? entry.required : true
|
||||
items.push({ id, text, required })
|
||||
items.push({
|
||||
id,
|
||||
text,
|
||||
description,
|
||||
type: itemType,
|
||||
options: itemOptions,
|
||||
required,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
|
|
@ -57,6 +94,9 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co
|
|||
items: (template.items ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
description: item.description,
|
||||
type: item.type ?? "checkbox",
|
||||
options: item.options,
|
||||
required: typeof item.required === "boolean" ? item.required : true,
|
||||
})),
|
||||
isArchived: Boolean(template.isArchived),
|
||||
|
|
@ -164,6 +204,9 @@ export const create = mutation({
|
|||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()),
|
||||
options: v.optional(v.array(v.string())),
|
||||
required: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
|
|
@ -216,6 +259,9 @@ export const update = mutation({
|
|||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()),
|
||||
options: v.optional(v.array(v.string())),
|
||||
required: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
|
|
@ -279,3 +325,52 @@ export const remove = mutation({
|
|||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
// DEBUG: Query para verificar dados do template e checklist de um ticket
|
||||
export const debugTemplateAndTicketChecklist = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
templateId: v.id("ticketChecklistTemplates"),
|
||||
ticketId: v.optional(v.id("tickets")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, templateId, ticketId }) => {
|
||||
await requireStaff(ctx, viewerId, tenantId)
|
||||
|
||||
const template = await ctx.db.get(templateId)
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
return { error: "Template nao encontrado" }
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
id: String(template._id),
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
hasDescription: Boolean(template.description),
|
||||
descriptionType: typeof template.description,
|
||||
itemsCount: template.items?.length ?? 0,
|
||||
}
|
||||
|
||||
let ticketData = null
|
||||
if (ticketId) {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (ticket && ticket.tenantId === tenantId) {
|
||||
ticketData = {
|
||||
id: String(ticket._id),
|
||||
checklistCount: ticket.checklist?.length ?? 0,
|
||||
checklistItems: (ticket.checklist ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
text: item.text.substring(0, 50),
|
||||
templateId: item.templateId ? String(item.templateId) : null,
|
||||
templateDescription: item.templateDescription,
|
||||
hasTemplateDescription: Boolean(item.templateDescription),
|
||||
description: item.description,
|
||||
hasDescription: Boolean(item.description),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { template: templateData, ticket: ticketData }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
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,
|
||||
})
|
||||
|
||||
return { sessionId, isNew: true }
|
||||
// Iniciar timer automaticamente se nao houver sessao de trabalho ativa
|
||||
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
|
||||
let workSessionId: Id<"ticketWorkSessions"> | null = null
|
||||
if (!ticket.activeSessionId && ticket.assigneeId) {
|
||||
workSessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||
ticketId,
|
||||
agentId: ticket.assigneeId,
|
||||
workType: "EXTERNAL",
|
||||
startedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
working: true,
|
||||
activeSessionId: workSessionId,
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "WORK_STARTED",
|
||||
payload: {
|
||||
actorId,
|
||||
actorName: agent.name,
|
||||
actorAvatar: agent.avatarUrl,
|
||||
sessionId: workSessionId,
|
||||
workType: "EXTERNAL",
|
||||
source: "live_chat_auto",
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -225,7 +258,60 @@ export const endSession = mutation({
|
|||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
// Pausar timer automaticamente se houver sessao de trabalho ativa
|
||||
let workSessionPaused = false
|
||||
const ticket = await ctx.db.get(session.ticketId)
|
||||
if (ticket?.activeSessionId) {
|
||||
const workSession = await ctx.db.get(ticket.activeSessionId)
|
||||
if (workSession && !workSession.stoppedAt) {
|
||||
const workDurationMs = now - workSession.startedAt
|
||||
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
|
||||
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
|
||||
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
|
||||
|
||||
// Encerrar sessao de trabalho
|
||||
await ctx.db.patch(ticket.activeSessionId, {
|
||||
stoppedAt: now,
|
||||
durationMs: workDurationMs,
|
||||
pauseReason: "END_LIVE_CHAT",
|
||||
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||
})
|
||||
|
||||
// Atualizar ticket
|
||||
await ctx.db.patch(session.ticketId, {
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
status: "PAUSED",
|
||||
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
|
||||
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Registrar evento de pausa
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: session.ticketId,
|
||||
type: "WORK_PAUSED",
|
||||
payload: {
|
||||
actorId,
|
||||
actorName: actor.name,
|
||||
actorAvatar: actor.avatarUrl,
|
||||
sessionId: workSession._id,
|
||||
sessionDurationMs: workDurationMs,
|
||||
workType: sessionType,
|
||||
pauseReason: "END_LIVE_CHAT",
|
||||
pauseReasonLabel: "Chat ao vivo encerrado",
|
||||
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||
source: "live_chat_auto",
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
workSessionPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, workSessionPaused }
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -417,8 +503,14 @@ export const listMachineSessions = query({
|
|||
// Proteção: limita sessões ativas retornadas (evita scan completo em caso de leak)
|
||||
.take(50)
|
||||
|
||||
// Filtrar apenas sessão problemática legada (ID hardcoded)
|
||||
// Nota: lastAgentMessageAt pode ser undefined em sessões novas onde o agente ainda não enviou mensagem
|
||||
const validSessions = sessions.filter(
|
||||
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j"
|
||||
)
|
||||
|
||||
const result = await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
validSessions.map(async (session) => {
|
||||
const ticket = await ctx.db.get(session.ticketId)
|
||||
return {
|
||||
sessionId: session._id,
|
||||
|
|
@ -520,13 +612,18 @@ export const checkMachineUpdates = query({
|
|||
const { machine } = await validateMachineToken(ctx, args.machineToken)
|
||||
|
||||
// Protecao: limita sessoes ativas retornadas (evita scan completo em caso de leak)
|
||||
const sessions = await ctx.db
|
||||
const rawSessions = await ctx.db
|
||||
.query("liveChatSessions")
|
||||
.withIndex("by_machine_status", (q) =>
|
||||
q.eq("machineId", machine._id).eq("status", "ACTIVE")
|
||||
)
|
||||
.take(50)
|
||||
|
||||
// Filtrar sessões problemáticas (sem campos obrigatórios)
|
||||
const sessions = rawSessions.filter(
|
||||
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
|
||||
)
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return {
|
||||
hasActiveSessions: false,
|
||||
|
|
@ -763,27 +860,40 @@ export const getTicketChatHistory = query({
|
|||
// Timeout de maquina offline: 5 minutos sem heartbeat
|
||||
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron)
|
||||
// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat
|
||||
// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens
|
||||
// Timeout de inatividade do chat: 12 horas sem atividade
|
||||
// Isso evita acumular sessoes abertas indefinidamente quando usuario esquece de encerrar
|
||||
const CHAT_INACTIVITY_TIMEOUT_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
|
||||
// Critérios de encerramento:
|
||||
// 1. Máquina offline (5 min sem heartbeat)
|
||||
// 2. Chat inativo (12 horas sem atividade) - mesmo se máquina online
|
||||
// 3. Ticket órfão (sem máquina vinculada)
|
||||
export const autoEndInactiveSessions = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)")
|
||||
console.log("cron: autoEndInactiveSessions iniciado")
|
||||
const now = Date.now()
|
||||
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
|
||||
const inactivityCutoff = now - CHAT_INACTIVITY_TIMEOUT_MS
|
||||
|
||||
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
|
||||
const maxSessionsPerRun = 50
|
||||
|
||||
// Buscar todas as sessões ativas
|
||||
const activeSessions = await ctx.db
|
||||
const rawActiveSessions = await ctx.db
|
||||
.query("liveChatSessions")
|
||||
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
|
||||
.take(maxSessionsPerRun)
|
||||
|
||||
// Filtrar sessões problemáticas (sem campos obrigatórios)
|
||||
const activeSessions = rawActiveSessions.filter(
|
||||
(s) => s._id !== "pd71bvfbxx7th3npdj519hcf3s7xbe2j" && s.lastAgentMessageAt !== undefined
|
||||
)
|
||||
|
||||
let endedCount = 0
|
||||
let checkedCount = 0
|
||||
const reasons: Record<string, number> = {}
|
||||
|
||||
for (const session of activeSessions) {
|
||||
checkedCount++
|
||||
|
|
@ -812,6 +922,36 @@ export const autoEndInactiveSessions = mutation({
|
|||
createdAt: now,
|
||||
})
|
||||
endedCount++
|
||||
reasons["ticket_sem_maquina"] = (reasons["ticket_sem_maquina"] ?? 0) + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Verificar inatividade do chat (12 horas sem atividade)
|
||||
// Isso tem prioridade sobre o status da máquina
|
||||
const chatIsInactive = session.lastActivityAt < inactivityCutoff
|
||||
if (chatIsInactive) {
|
||||
await ctx.db.patch(session._id, {
|
||||
status: "ENDED",
|
||||
endedAt: now,
|
||||
})
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: session.ticketId,
|
||||
type: "LIVE_CHAT_ENDED",
|
||||
payload: {
|
||||
sessionId: session._id,
|
||||
agentId: session.agentId,
|
||||
agentName: session.agentSnapshot?.name ?? "Sistema",
|
||||
durationMs: now - session.startedAt,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: now,
|
||||
autoEnded: true,
|
||||
reason: "inatividade_chat",
|
||||
inactiveForMs: now - session.lastActivityAt,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
endedCount++
|
||||
reasons["inatividade_chat"] = (reasons["inatividade_chat"] ?? 0) + 1
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -819,7 +959,7 @@ export const autoEndInactiveSessions = mutation({
|
|||
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
|
||||
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
|
||||
|
||||
// Se máquina está online, manter sessão ativa
|
||||
// Se máquina está online e chat está ativo, manter sessão
|
||||
if (machineIsOnline) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -849,10 +989,40 @@ export const autoEndInactiveSessions = mutation({
|
|||
})
|
||||
|
||||
endedCount++
|
||||
reasons["maquina_offline"] = (reasons["maquina_offline"] ?? 0) + 1
|
||||
}
|
||||
|
||||
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`)
|
||||
return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun }
|
||||
const reasonsSummary = Object.entries(reasons).map(([r, c]) => `${r}=${c}`).join(", ")
|
||||
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (${reasonsSummary || "nenhuma"})`)
|
||||
return { endedCount, checkedCount, reasons, hasMore: activeSessions.length === maxSessionsPerRun }
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation para corrigir sessoes antigas sem campos obrigatorios
|
||||
export const fixLegacySessions = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// IDs problematicos conhecidos - sessoes sem lastAgentMessageAt
|
||||
const knownProblematicIds = [
|
||||
"pd71bvfbxx7th3npdj519hcf3s7xbe2j",
|
||||
]
|
||||
|
||||
let deleted = 0
|
||||
const results: string[] = []
|
||||
|
||||
for (const sessionId of knownProblematicIds) {
|
||||
try {
|
||||
// Deletar a sessao problematica diretamente (evita erro de shape ao ler)
|
||||
await ctx.db.delete(sessionId as Id<"liveChatSessions">)
|
||||
deleted++
|
||||
results.push(`${sessionId}: deleted`)
|
||||
} catch (error) {
|
||||
results.push(`${sessionId}: error - ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`fixLegacySessions: deleted=${deleted}, results=${results.join(", ")}`)
|
||||
return { deleted, results }
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -867,6 +1037,13 @@ const ALLOWED_MIME_TYPES = [
|
|||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
// Audio
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
// Documentos
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
|
|
@ -878,6 +1055,7 @@ const ALLOWED_MIME_TYPES = [
|
|||
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||
".webm", ".ogg", ".wav", ".mp3", ".m4a",
|
||||
".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(", ")}`)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
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)
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { api } from "./_generated/api"
|
||||
import { internal, api } from "./_generated/api"
|
||||
import { paginationOptsValidator } from "convex/server"
|
||||
import { ConvexError, v, Infer } from "convex/values"
|
||||
import { sha256 } from "@noble/hashes/sha2.js"
|
||||
|
|
@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
|
|||
return hb?.lastHeartbeatAt ?? fallback ?? null
|
||||
}
|
||||
|
||||
// Campos do inventory que sao muito grandes e nao devem ser persistidos
|
||||
// para evitar OOM no Convex (documentos de ~100KB cada)
|
||||
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
|
||||
// Campo software é muito grande e é tratado separadamente via machineSoftware
|
||||
|
||||
// Extrai campos importantes do extended antes de bloqueá-lo
|
||||
function extractFromExtended(extended: unknown): JsonRecord {
|
||||
const result: JsonRecord = {}
|
||||
const sanitizedExtended = sanitizeRecord(extended)
|
||||
if (!sanitizedExtended) return result
|
||||
|
||||
// Extrair dados do Windows
|
||||
const windows = sanitizeRecord(sanitizedExtended["windows"])
|
||||
if (windows) {
|
||||
const windowsFields: JsonRecord = {}
|
||||
// bootInfo - informacoes de reinicio
|
||||
if (windows["bootInfo"]) {
|
||||
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
|
||||
}
|
||||
// osInfo - informacoes do sistema operacional
|
||||
if (windows["osInfo"]) {
|
||||
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
|
||||
}
|
||||
// cpu, baseboard, bios, memoryModules, videoControllers, disks
|
||||
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
|
||||
if (windows[key]) {
|
||||
windowsFields[key] = windows[key] as JsonValue
|
||||
}
|
||||
}
|
||||
if (Object.keys(windowsFields).length > 0) {
|
||||
result["windows"] = windowsFields
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair dados do Linux
|
||||
const linux = sanitizeRecord(sanitizedExtended["linux"])
|
||||
if (linux) {
|
||||
const linuxFields: JsonRecord = {}
|
||||
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
|
||||
if (linux[key]) {
|
||||
linuxFields[key] = linux[key] as JsonValue
|
||||
}
|
||||
}
|
||||
if (Object.keys(linuxFields).length > 0) {
|
||||
result["linux"] = linuxFields
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair dados do macOS
|
||||
const macos = sanitizeRecord(sanitizedExtended["macos"])
|
||||
if (macos) {
|
||||
result["macos"] = macos as JsonValue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
|
||||
const sanitizedPatch = sanitizeRecord(patch)
|
||||
|
|
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
|
|||
return current ? { ...current } : {}
|
||||
}
|
||||
const base: JsonRecord = current ? { ...current } : {}
|
||||
|
||||
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
||||
// Filtrar campos volumosos que causam OOM
|
||||
if (INVENTORY_BLOCKLIST.has(key)) continue
|
||||
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
|
||||
if (key === "software") continue
|
||||
if (value === undefined) continue
|
||||
if (isObject(value) && isObject(base[key])) {
|
||||
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 {
|
||||
const record = sanitizeRecord(value)
|
||||
if (!record) return null
|
||||
for (const blocked of INVENTORY_BLOCKLIST) {
|
||||
delete record[blocked]
|
||||
|
||||
// Extrair campos importantes do extended antes de deletá-lo
|
||||
if (record["extended"]) {
|
||||
const extractedExtended = extractFromExtended(record["extended"])
|
||||
if (Object.keys(extractedExtended).length > 0) {
|
||||
record["extended"] = extractedExtended
|
||||
} else {
|
||||
delete record["extended"]
|
||||
}
|
||||
}
|
||||
|
||||
// Deletar apenas software (extended já foi processado acima)
|
||||
delete record["software"]
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
|
|
@ -956,10 +1018,13 @@ export const heartbeat = mutation({
|
|||
}
|
||||
}
|
||||
|
||||
const sanitizedInventory = sanitizeInventoryPayload(args.inventory)
|
||||
// Extrair inventory de args.inventory ou de args.metadata.inventory (agente envia em metadata)
|
||||
const rawInventory = args.inventory ?? (incomingMeta?.["inventory"] as Record<string, unknown> | undefined)
|
||||
const sanitizedInventory = sanitizeInventoryPayload(rawInventory)
|
||||
const currentInventory = ensureRecord(currentMetadata.inventory)
|
||||
const incomingInventoryHash = hashJson(sanitizedInventory)
|
||||
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
|
||||
|
||||
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
|
||||
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
|
||||
metadataPatch.inventoryHash = incomingInventoryHash
|
||||
|
|
@ -1010,6 +1075,34 @@ export const heartbeat = mutation({
|
|||
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
|
||||
}
|
||||
|
||||
// Processar softwares instalados (armazenados em tabela separada)
|
||||
// Os dados de software sao extraidos ANTES de sanitizar o inventory
|
||||
// Usa rawInventory ja extraido anteriormente (linha ~1022)
|
||||
if (rawInventory && typeof rawInventory === "object") {
|
||||
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
|
||||
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
|
||||
const validSoftware = softwareArray
|
||||
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
|
||||
.map((item) => ({
|
||||
name: typeof item.name === "string" ? item.name : "",
|
||||
version: typeof item.version === "string" ? item.version : undefined,
|
||||
publisher: typeof item.publisher === "string" || typeof item.source === "string"
|
||||
? (item.publisher as string) || (item.source as string)
|
||||
: undefined,
|
||||
source: typeof item.source === "string" ? item.source : undefined,
|
||||
}))
|
||||
.filter((item) => item.name.length > 0)
|
||||
|
||||
if (validSoftware.length > 0) {
|
||||
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
|
||||
tenantId: machine.tenantId,
|
||||
machineId: machine._id,
|
||||
software: validSoftware,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(token._id, {
|
||||
lastUsedAt: now,
|
||||
usageCount: (token.usageCount ?? 0) + 1,
|
||||
|
|
@ -2317,6 +2410,44 @@ export const resetAgent = mutation({
|
|||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Query para o desktop monitorar o estado da máquina em tempo real.
|
||||
* O desktop faz subscribe nessa query e reage imediatamente quando:
|
||||
* - isActive muda para false (desativação)
|
||||
* - hasValidToken muda para false (reset/revogação de tokens)
|
||||
*/
|
||||
export const getMachineState = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
},
|
||||
handler: async (ctx, { machineId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
|
||||
}
|
||||
|
||||
// Verifica se existe algum token válido (não revogado e não expirado)
|
||||
const now = Date.now()
|
||||
const tokens = await ctx.db
|
||||
.query("machineTokens")
|
||||
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
||||
.take(10)
|
||||
|
||||
const hasValidToken = tokens.some((token) => {
|
||||
if (token.revoked) return false
|
||||
if (token.expiresAt && token.expiresAt < now) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
found: true,
|
||||
isActive: machine.isActive ?? true,
|
||||
hasValidToken,
|
||||
status: machine.status ?? "unknown",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type RemoteAccessEntry = {
|
||||
id: string
|
||||
provider: string
|
||||
|
|
|
|||
|
|
@ -1043,3 +1043,81 @@ export const backfillTicketSnapshots = mutation({
|
|||
return { processed }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Migration para remover comentarios duplicados de troca de responsavel.
|
||||
* Esses comentarios eram criados automaticamente ao trocar o responsavel,
|
||||
* mas essa informacao ja aparece na linha do tempo (ticketEvents).
|
||||
* O comentario segue o padrao: "<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();
|
||||
for (const ticket of tickets) {
|
||||
const status = normalizeStatus(ticket.status);
|
||||
const isWorking = ticket.working === true;
|
||||
if (status === "PENDING") {
|
||||
pending += 1;
|
||||
} else if (status === "AWAITING_ATTENDANCE") {
|
||||
inProgress += 1;
|
||||
// "Em andamento" conta apenas tickets com play ativo
|
||||
if (isWorking) {
|
||||
inProgress += 1;
|
||||
} else {
|
||||
// Tickets em atendimento sem play ativo contam como "Em aberto"
|
||||
pending += 1;
|
||||
}
|
||||
} else if (status === "PAUSED") {
|
||||
paused += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,31 @@ import { render } from "@react-email/render"
|
|||
|
||||
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
||||
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email"
|
||||
import InviteEmail, { type InviteEmailProps } from "../emails/invite-email"
|
||||
import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email"
|
||||
import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email"
|
||||
import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email"
|
||||
import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email"
|
||||
import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email"
|
||||
import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email"
|
||||
import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email"
|
||||
import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email"
|
||||
import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email"
|
||||
|
||||
export type { AutomationEmailProps, SimpleNotificationEmailProps }
|
||||
export type {
|
||||
AutomationEmailProps,
|
||||
SimpleNotificationEmailProps,
|
||||
InviteEmailProps,
|
||||
PasswordResetEmailProps,
|
||||
NewLoginEmailProps,
|
||||
SlaWarningEmailProps,
|
||||
SlaBreachedEmailProps,
|
||||
TicketCreatedEmailProps,
|
||||
TicketResolvedEmailProps,
|
||||
TicketAssignedEmailProps,
|
||||
TicketStatusEmailProps,
|
||||
TicketCommentEmailProps,
|
||||
}
|
||||
|
||||
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||
return render(<AutomationEmail {...props} />, { pretty: false })
|
||||
|
|
@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
|||
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
||||
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderInviteEmailHtml(props: InviteEmailProps) {
|
||||
return render(<InviteEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) {
|
||||
return render(<PasswordResetEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) {
|
||||
return render(<NewLoginEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) {
|
||||
return render(<SlaWarningEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) {
|
||||
return render(<SlaBreachedEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) {
|
||||
return render(<TicketCreatedEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) {
|
||||
return render(<TicketResolvedEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) {
|
||||
return render(<TicketAssignedEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) {
|
||||
return render(<TicketStatusEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
||||
export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) {
|
||||
return render(<TicketCommentEmail {...props} />, { pretty: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,11 +161,8 @@ async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks
|
|||
}
|
||||
}
|
||||
|
||||
function logDashboardProgress(processed: number, tenantId: string) {
|
||||
const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024));
|
||||
console.log(
|
||||
`[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`,
|
||||
);
|
||||
function logDashboardProgress(_processed: number, _tenantId: string) {
|
||||
// Log de progresso removido para reduzir ruido no console
|
||||
}
|
||||
|
||||
function mapToChronologicalSeries(map: Map<string, number>) {
|
||||
|
|
@ -2406,18 +2403,20 @@ export const companyOverview = query({
|
|||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.id("companies"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
range: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) {
|
||||
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
|
||||
}
|
||||
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||
|
||||
const company = await ctx.db.get(companyId);
|
||||
if (!company || company.tenantId !== tenantId) {
|
||||
throw new ConvexError("Empresa não encontrada");
|
||||
// Buscar dados da empresa selecionada (se houver)
|
||||
let company: Doc<"companies"> | null = null;
|
||||
if (scopedCompanyId) {
|
||||
company = await ctx.db.get(scopedCompanyId);
|
||||
if (!company || company.tenantId !== tenantId) {
|
||||
throw new ConvexError("Empresa não encontrada");
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRange = (range ?? "30d").toLowerCase();
|
||||
|
|
@ -2426,20 +2425,35 @@ export const companyOverview = query({
|
|||
const startMs = now - rangeDays * ONE_DAY_MS;
|
||||
|
||||
// Limita consultas para evitar OOM em empresas muito grandes
|
||||
const tickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
.take(2000);
|
||||
const tickets = scopedCompanyId
|
||||
? await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||
.take(2000)
|
||||
: await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.take(2000);
|
||||
|
||||
const machines = await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
.take(1000);
|
||||
const machines = scopedCompanyId
|
||||
? await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||
.take(1000)
|
||||
: await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.take(1000);
|
||||
|
||||
const users = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
.take(500);
|
||||
const users = scopedCompanyId
|
||||
? await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||
.take(500)
|
||||
: await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.take(500);
|
||||
|
||||
const statusCounts = {} as Record<string, number>;
|
||||
const priorityCounts = {} as Record<string, number>;
|
||||
|
|
@ -2534,11 +2548,13 @@ export const companyOverview = query({
|
|||
});
|
||||
|
||||
return {
|
||||
company: {
|
||||
id: company._id,
|
||||
name: company.name,
|
||||
isAvulso: company.isAvulso ?? false,
|
||||
},
|
||||
company: company
|
||||
? {
|
||||
id: company._id,
|
||||
name: company.name,
|
||||
isAvulso: company.isAvulso ?? false,
|
||||
}
|
||||
: null,
|
||||
rangeDays,
|
||||
generatedAt: now,
|
||||
tickets: {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export default defineSchema({
|
|||
contacts: v.optional(v.any()),
|
||||
locations: v.optional(v.any()),
|
||||
sla: v.optional(v.any()),
|
||||
reopenWindowDays: v.optional(v.number()),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
customFields: v.optional(v.any()),
|
||||
notes: v.optional(v.string()),
|
||||
|
|
@ -199,7 +200,11 @@ export default defineSchema({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()), // minutes
|
||||
responseMode: v.optional(v.string()), // "business" | "calendar"
|
||||
timeToResolution: v.optional(v.number()), // minutes
|
||||
solutionMode: v.optional(v.string()), // "business" | "calendar"
|
||||
alertThreshold: v.optional(v.number()), // 0.1 a 0.95
|
||||
pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA
|
||||
}).index("by_tenant_name", ["tenantId", "name"]),
|
||||
|
||||
tickets: defineTable({
|
||||
|
|
@ -314,10 +319,15 @@ export default defineSchema({
|
|||
v.object({
|
||||
id: v.string(),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()), // "checkbox" | "question"
|
||||
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
|
||||
answer: v.optional(v.string()), // Resposta selecionada para tipo "question"
|
||||
done: v.boolean(),
|
||||
required: v.optional(v.boolean()),
|
||||
templateId: v.optional(v.id("ticketChecklistTemplates")),
|
||||
templateItemId: v.optional(v.string()),
|
||||
templateDescription: v.optional(v.string()), // Descricao do template (copiada ao aplicar)
|
||||
createdAt: v.optional(v.number()),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
doneAt: v.optional(v.number()),
|
||||
|
|
@ -478,6 +488,7 @@ export default defineSchema({
|
|||
startedAt: v.number(),
|
||||
endedAt: v.optional(v.number()),
|
||||
lastActivityAt: v.number(),
|
||||
lastAgentMessageAt: v.optional(v.number()), // Timestamp da ultima mensagem do agente (para deteccao confiavel)
|
||||
unreadByMachine: v.optional(v.number()),
|
||||
unreadByAgent: v.optional(v.number()),
|
||||
})
|
||||
|
|
@ -587,6 +598,29 @@ export default defineSchema({
|
|||
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
|
||||
.index("by_tenant_category", ["tenantId", "categoryId"]),
|
||||
|
||||
// SLA por empresa - permite configurar políticas de SLA específicas por cliente
|
||||
// Quando um ticket é criado, o sistema busca primeiro aqui antes de usar categorySlaSettings
|
||||
companySlaSettings: defineTable({
|
||||
tenantId: v.string(),
|
||||
companyId: v.id("companies"),
|
||||
// Se categoryId for null, aplica-se a todas as categorias da empresa
|
||||
categoryId: v.optional(v.id("ticketCategories")),
|
||||
priority: v.string(), // URGENT, HIGH, MEDIUM, LOW, DEFAULT
|
||||
responseTargetMinutes: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()), // "business" | "calendar"
|
||||
solutionTargetMinutes: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()), // "business" | "calendar"
|
||||
alertThreshold: v.optional(v.number()), // 0.1 a 0.95 (ex: 0.8 = 80%)
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
calendarType: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_company_category", ["tenantId", "companyId", "categoryId"])
|
||||
.index("by_tenant_company_category_priority", ["tenantId", "companyId", "categoryId", "priority"]),
|
||||
|
||||
ticketFields: defineTable({
|
||||
tenantId: v.string(),
|
||||
key: v.string(),
|
||||
|
|
@ -658,6 +692,9 @@ export default defineSchema({
|
|||
v.object({
|
||||
id: v.string(),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()), // "checkbox" | "question"
|
||||
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
|
||||
required: v.optional(v.boolean()),
|
||||
})
|
||||
),
|
||||
|
|
@ -788,6 +825,25 @@ export default defineSchema({
|
|||
})
|
||||
.index("by_machine", ["machineId"]),
|
||||
|
||||
// Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao
|
||||
// Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada
|
||||
machineSoftware: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
name: v.string(),
|
||||
nameLower: v.string(), // Para busca case-insensitive
|
||||
version: v.optional(v.string()),
|
||||
publisher: v.optional(v.string()),
|
||||
source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc
|
||||
installedAt: v.optional(v.number()), // Data de instalacao (se disponivel)
|
||||
detectedAt: v.number(), // Quando foi detectado pelo agente
|
||||
lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat
|
||||
})
|
||||
.index("by_machine", ["machineId"])
|
||||
.index("by_machine_name", ["machineId", "nameLower"])
|
||||
.index("by_tenant_name", ["tenantId", "nameLower"])
|
||||
.index("by_tenant_machine", ["tenantId", "machineId"]),
|
||||
|
||||
machineTokens: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,26 @@ function normalizeName(value: string) {
|
|||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeMode(value?: string): "business" | "calendar" {
|
||||
if (value === "business") return "business";
|
||||
return "calendar";
|
||||
}
|
||||
|
||||
function normalizeThreshold(value?: number): number {
|
||||
if (value === undefined || value === null) return 0.8;
|
||||
if (value < 0.1) return 0.1;
|
||||
if (value > 0.95) return 0.95;
|
||||
return value;
|
||||
}
|
||||
|
||||
const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const;
|
||||
|
||||
function normalizePauseStatuses(statuses?: string[]): string[] {
|
||||
if (!statuses || statuses.length === 0) return ["PAUSED"];
|
||||
const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number]));
|
||||
return filtered.length > 0 ? filtered : ["PAUSED"];
|
||||
}
|
||||
|
||||
type AnyCtx = QueryCtx | MutationCtx;
|
||||
|
||||
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
||||
|
|
@ -35,7 +55,11 @@ export const list = query({
|
|||
name: policy.name,
|
||||
description: policy.description ?? "",
|
||||
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
||||
responseMode: policy.responseMode ?? "calendar",
|
||||
timeToResolution: policy.timeToResolution ?? null,
|
||||
solutionMode: policy.solutionMode ?? "calendar",
|
||||
alertThreshold: policy.alertThreshold ?? 0.8,
|
||||
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -47,9 +71,14 @@ export const create = mutation({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
timeToResolution: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
||||
handler: async (ctx, args) => {
|
||||
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const trimmed = normalizeName(name);
|
||||
if (trimmed.length < 2) {
|
||||
|
|
@ -68,7 +97,11 @@ export const create = mutation({
|
|||
name: trimmed,
|
||||
description,
|
||||
timeToFirstResponse,
|
||||
responseMode: normalizeMode(responseMode),
|
||||
timeToResolution,
|
||||
solutionMode: normalizeMode(solutionMode),
|
||||
alertThreshold: normalizeThreshold(alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
|
@ -82,9 +115,14 @@ export const update = mutation({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
timeToResolution: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
||||
handler: async (ctx, args) => {
|
||||
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const policy = await ctx.db.get(policyId);
|
||||
if (!policy || policy.tenantId !== tenantId) {
|
||||
|
|
@ -106,7 +144,11 @@ export const update = mutation({
|
|||
name: trimmed,
|
||||
description,
|
||||
timeToFirstResponse,
|
||||
responseMode: normalizeMode(responseMode),
|
||||
timeToResolution,
|
||||
solutionMode: normalizeMode(solutionMode),
|
||||
alertThreshold: normalizeThreshold(alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,38 @@
|
|||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
export type ChecklistItemType = "checkbox" | "question"
|
||||
|
||||
export type TicketChecklistItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: ChecklistItemType
|
||||
options?: string[] // Para tipo "question": ["Sim", "Nao", ...]
|
||||
answer?: string // Resposta selecionada para tipo "question"
|
||||
done: boolean
|
||||
required?: boolean
|
||||
templateId?: Id<"ticketChecklistTemplates">
|
||||
templateItemId?: string
|
||||
templateDescription?: string // Descricao do template (copiada ao aplicar)
|
||||
createdAt?: number
|
||||
createdBy?: Id<"users">
|
||||
doneAt?: number
|
||||
doneBy?: Id<"users">
|
||||
}
|
||||
|
||||
export type TicketChecklistTemplateItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: string // "checkbox" | "question" - string para compatibilidade com schema
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export type TicketChecklistTemplateLike = {
|
||||
_id: Id<"ticketChecklistTemplates">
|
||||
items: Array<{ id: string; text: string; required?: boolean }>
|
||||
description?: string
|
||||
items: TicketChecklistTemplateItem[]
|
||||
}
|
||||
|
||||
export function normalizeChecklistText(input: string) {
|
||||
|
|
@ -53,13 +70,18 @@ export function applyChecklistTemplateToItems(
|
|||
const key = `${String(template._id)}:${templateItemId}`
|
||||
if (existingKeys.has(key)) continue
|
||||
existingKeys.add(key)
|
||||
const itemType = tplItem.type ?? "checkbox"
|
||||
next.push({
|
||||
id: generateId(),
|
||||
text,
|
||||
description: tplItem.description,
|
||||
type: itemType as ChecklistItemType,
|
||||
options: itemType === "question" ? tplItem.options : undefined,
|
||||
done: false,
|
||||
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
|
||||
templateId: template._id,
|
||||
templateItemId,
|
||||
templateDescription: template.description,
|
||||
createdAt: now,
|
||||
createdBy: options.actorId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,45 @@ import { v } from "convex/values"
|
|||
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
|
||||
// API do Next.js para verificar preferências
|
||||
async function sendViaNextApi(params: {
|
||||
type: string
|
||||
to: { email: string; name?: string; userId?: string }
|
||||
subject: string
|
||||
data: Record<string, unknown>
|
||||
tenantId?: string
|
||||
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||
|
||||
if (!token) {
|
||||
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
|
||||
return { success: false, reason: "no_token" }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/notifications/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error("[ticketNotifications] Erro na API:", error)
|
||||
return { success: false, reason: "api_error" }
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("[ticketNotifications] Erro ao chamar API:", error)
|
||||
return { success: false, reason: "fetch_error" }
|
||||
}
|
||||
}
|
||||
|
||||
function b64(input: string) {
|
||||
return Buffer.from(input, "utf8").toString("base64")
|
||||
}
|
||||
|
|
@ -281,25 +320,109 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
|
|||
}
|
||||
}
|
||||
|
||||
export const sendPublicCommentEmail = action({
|
||||
export const sendTicketCreatedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
priority: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
|
||||
const priorityLabels: Record<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()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket comment email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Nova atualização no seu chamado #${reference}`,
|
||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||
message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
|
||||
ctaLabel: "Abrir e responder",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
@ -311,22 +434,45 @@ export const sendPublicCommentEmail = action({
|
|||
export const sendResolvedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "ticket_resolved",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Chamado #${reference} encerrado`,
|
||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
ctaLabel: "Ver detalhes",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
@ -339,9 +485,23 @@ export const sendAutomationEmail = action({
|
|||
args: {
|
||||
to: v.array(v.string()),
|
||||
subject: v.string(),
|
||||
html: v.string(),
|
||||
emailProps: v.object({
|
||||
title: v.string(),
|
||||
message: v.string(),
|
||||
ticket: v.object({
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
status: v.optional(v.union(v.string(), v.null())),
|
||||
priority: v.optional(v.union(v.string(), v.null())),
|
||||
companyName: v.optional(v.union(v.string(), v.null())),
|
||||
requesterName: v.optional(v.union(v.string(), v.null())),
|
||||
assigneeName: v.optional(v.union(v.string(), v.null())),
|
||||
}),
|
||||
ctaLabel: v.string(),
|
||||
ctaUrl: v.string(),
|
||||
}),
|
||||
},
|
||||
handler: async (_ctx, { to, subject, html }) => {
|
||||
handler: async (_ctx, { to, subject, emailProps }) => {
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping automation email")
|
||||
|
|
@ -357,10 +517,45 @@ export const sendAutomationEmail = action({
|
|||
return { skipped: true, reason: "no_recipients" }
|
||||
}
|
||||
|
||||
// Renderiza o HTML aqui (ambiente Node.js suporta imports dinâmicos)
|
||||
const { renderAutomationEmailHtml } = await import("./reactEmail")
|
||||
const html = await renderAutomationEmailHtml({
|
||||
title: emailProps.title,
|
||||
message: emailProps.message,
|
||||
ticket: {
|
||||
reference: emailProps.ticket.reference,
|
||||
subject: emailProps.ticket.subject,
|
||||
status: emailProps.ticket.status ?? null,
|
||||
priority: emailProps.ticket.priority ?? null,
|
||||
companyName: emailProps.ticket.companyName ?? null,
|
||||
requesterName: emailProps.ticket.requesterName ?? null,
|
||||
assigneeName: emailProps.ticket.assigneeName ?? null,
|
||||
},
|
||||
ctaLabel: emailProps.ctaLabel,
|
||||
ctaUrl: emailProps.ctaUrl,
|
||||
})
|
||||
|
||||
const results: Array<{ recipient: string; sent: boolean; error?: string }> = []
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await sendSmtpMail(smtp, recipient, subject, html)
|
||||
try {
|
||||
await sendSmtpMail(smtp, recipient, subject, html)
|
||||
results.push({ recipient, sent: true })
|
||||
console.log(`[automation-email] Enviado para ${recipient}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
results.push({ recipient, sent: false, error: errorMessage })
|
||||
console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, sent: recipients.length }
|
||||
const sent = results.filter((r) => r.sent).length
|
||||
const failed = results.filter((r) => !r.sent).length
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`)
|
||||
}
|
||||
|
||||
return { ok: sent > 0, sent, failed, results }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
|||
NO_CONTACT: "Falta de contato",
|
||||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||
IN_PROCEDURE: "Em procedimento",
|
||||
END_LIVE_CHAT: "Chat ao vivo encerrado",
|
||||
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
|
||||
};
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
|
@ -272,25 +273,74 @@ async function resolveTicketSlaSnapshot(
|
|||
ctx: AnyCtx,
|
||||
tenantId: string,
|
||||
category: Doc<"ticketCategories"> | null,
|
||||
priority: string
|
||||
priority: string,
|
||||
companyId?: Id<"companies"> | null
|
||||
): Promise<TicketSlaSnapshot | null> {
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPriority = priority.trim().toUpperCase();
|
||||
const rule =
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||
|
||||
// 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado)
|
||||
let rule: {
|
||||
responseTargetMinutes?: number;
|
||||
responseMode?: string;
|
||||
solutionTargetMinutes?: number;
|
||||
solutionMode?: string;
|
||||
alertThreshold?: number;
|
||||
pauseStatuses?: string[];
|
||||
} | null = null;
|
||||
|
||||
if (companyId) {
|
||||
// Tenta: empresa + categoria + prioridade
|
||||
rule = await ctx.db
|
||||
.query("companySlaSettings")
|
||||
.withIndex("by_tenant_company_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||
)
|
||||
.first()) ??
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||
)
|
||||
.first());
|
||||
.first();
|
||||
|
||||
// Fallback: empresa + categoria + DEFAULT
|
||||
if (!rule) {
|
||||
rule = await ctx.db
|
||||
.query("companySlaSettings")
|
||||
.withIndex("by_tenant_company_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
// Fallback: empresa + todas categorias (categoryId null) + prioridade
|
||||
if (!rule) {
|
||||
const allCategoriesRules = await ctx.db
|
||||
.query("companySlaSettings")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
.filter((q) => q.eq(q.field("categoryId"), undefined))
|
||||
.take(10);
|
||||
|
||||
rule = allCategoriesRules.find((r) => r.priority === normalizedPriority) ??
|
||||
allCategoriesRules.find((r) => r.priority === "DEFAULT") ??
|
||||
null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Se não encontrou SLA da empresa, usa SLA da categoria (comportamento padrão)
|
||||
if (!rule) {
|
||||
rule =
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||
)
|
||||
.first()) ??
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||
)
|
||||
.first());
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -872,23 +922,6 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
|
|||
}
|
||||
}
|
||||
|
||||
export function buildAssigneeChangeComment(
|
||||
reason: string,
|
||||
context: { previousName: string; nextName: string },
|
||||
): string {
|
||||
const normalized = reason.replace(/\r\n/g, "\n").trim();
|
||||
const lines = normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const previous = escapeHtml(context.previousName || "Não atribuído");
|
||||
const next = escapeHtml(context.nextName || "Não atribuído");
|
||||
const reasonHtml = lines.length
|
||||
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
|
||||
: `<p>—</p>`;
|
||||
return `<p><strong>Responsável atualizado:</strong> ${previous} → ${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
|
||||
}
|
||||
|
||||
function truncateSubject(subject: string) {
|
||||
if (subject.length <= 60) return subject
|
||||
return `${subject.slice(0, 57)}…`
|
||||
|
|
@ -2098,10 +2131,15 @@ export const getById = query({
|
|||
? t.checklist.map((item) => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
description: item.description ?? undefined,
|
||||
type: item.type ?? "checkbox",
|
||||
options: item.options ?? undefined,
|
||||
answer: item.answer ?? undefined,
|
||||
done: item.done,
|
||||
required: typeof item.required === "boolean" ? item.required : true,
|
||||
templateId: item.templateId ? String(item.templateId) : undefined,
|
||||
templateItemId: item.templateItemId ?? undefined,
|
||||
templateDescription: item.templateDescription ?? undefined,
|
||||
createdAt: item.createdAt ?? undefined,
|
||||
createdBy: item.createdBy ? String(item.createdBy) : undefined,
|
||||
doneAt: item.doneAt ?? undefined,
|
||||
|
|
@ -2337,7 +2375,7 @@ export const create = mutation({
|
|||
avatarUrl: requester.avatarUrl ?? undefined,
|
||||
teams: requester.teams ?? undefined,
|
||||
}
|
||||
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
|
||||
// Resolve a empresa primeiro para poder verificar SLA específico
|
||||
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
|
||||
if (!companyDoc && machineDoc?.companyId) {
|
||||
const candidateCompany = await ctx.db.get(machineDoc.companyId)
|
||||
|
|
@ -2349,6 +2387,8 @@ export const create = mutation({
|
|||
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
|
||||
: undefined
|
||||
const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined
|
||||
// Resolve SLA passando companyId para verificar regras específicas da empresa
|
||||
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority, resolvedCompanyId)
|
||||
|
||||
let checklist = manualChecklist
|
||||
for (const templateId of args.checklistTemplateIds ?? []) {
|
||||
|
|
@ -2456,6 +2496,28 @@ export const create = mutation({
|
|||
createdAt: now,
|
||||
});
|
||||
|
||||
// Notificação por e-mail: ticket criado para o solicitante
|
||||
try {
|
||||
const requesterEmail = requester?.email
|
||||
if (requesterEmail) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
|
||||
to: requesterEmail,
|
||||
userId: String(requester._id),
|
||||
userName: requester.name ?? undefined,
|
||||
ticketId: String(id),
|
||||
reference: nextRef,
|
||||
subject,
|
||||
priority: args.priority,
|
||||
tenantId: args.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e)
|
||||
}
|
||||
|
||||
if (initialAssigneeId && initialAssignee) {
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
|
|
@ -2647,6 +2709,49 @@ export const setChecklistItemRequired = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const setChecklistItemAnswer = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
itemId: v.string(),
|
||||
answer: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
|
||||
const ticket = await ctx.db.get(ticketId);
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado");
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">;
|
||||
await requireTicketStaff(ctx, actorId, ticketDoc);
|
||||
|
||||
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
|
||||
const index = checklist.findIndex((item) => item.id === itemId);
|
||||
if (index < 0) {
|
||||
throw new ConvexError("Item do checklist não encontrado.");
|
||||
}
|
||||
|
||||
const item = checklist[index]!;
|
||||
if (item.type !== "question") {
|
||||
throw new ConvexError("Este item não é uma pergunta.");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const normalizedAnswer = answer?.trim() ?? "";
|
||||
const isDone = normalizedAnswer.length > 0;
|
||||
|
||||
const nextChecklist = checklist.map((it) => {
|
||||
if (it.id !== itemId) return it;
|
||||
if (isDone) {
|
||||
return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
|
||||
}
|
||||
return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
|
||||
});
|
||||
|
||||
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const removeChecklistItem = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
@ -2700,6 +2805,34 @@ export const completeAllChecklistItems = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const uncompleteAllChecklistItems = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId);
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado");
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">;
|
||||
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
|
||||
ensureChecklistEditor(viewer);
|
||||
|
||||
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
|
||||
if (checklist.length === 0) return { ok: true };
|
||||
|
||||
const now = Date.now();
|
||||
const nextChecklist = checklist.map((item) => {
|
||||
if (item.done === false) return item;
|
||||
return { ...item, done: false, doneAt: undefined, doneBy: undefined };
|
||||
});
|
||||
|
||||
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const applyChecklistTemplate = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
@ -2851,15 +2984,19 @@ export const addComment = mutation({
|
|||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||
// Notificação por e-mail: comentário público para o solicitante
|
||||
try {
|
||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const snapshotEmail = requesterSnapshot?.email
|
||||
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||
to: snapshotEmail,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName: requesterSnapshot?.name ?? undefined,
|
||||
ticketId: String(ticketDoc._id),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3090,7 +3227,18 @@ export async function resolveTicketHandler(
|
|||
throw new ConvexError("Chamado vinculado não encontrado")
|
||||
}
|
||||
|
||||
const reopenDays = resolveReopenWindowDays(reopenWindowDays)
|
||||
// Buscar prazo de reabertura da empresa do ticket (se existir)
|
||||
let companyReopenDays: number | null = null
|
||||
if (ticketDoc.companyId) {
|
||||
const company = await ctx.db.get(ticketDoc.companyId)
|
||||
if (company && typeof company.reopenWindowDays === "number") {
|
||||
companyReopenDays = company.reopenWindowDays
|
||||
}
|
||||
}
|
||||
|
||||
// Prioridade: 1) valor passado explicitamente, 2) valor da empresa, 3) padrão
|
||||
const effectiveReopenDays = reopenWindowDays ?? companyReopenDays
|
||||
const reopenDays = resolveReopenWindowDays(effectiveReopenDays)
|
||||
const reopenDeadline = computeReopenDeadline(now, reopenDays)
|
||||
const normalizedStatus = "RESOLVED"
|
||||
const relatedIdList = Array.from(
|
||||
|
|
@ -3127,16 +3275,21 @@ export async function resolveTicketHandler(
|
|||
|
||||
// Notificação por e-mail: encerramento do chamado
|
||||
try {
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const email = requesterDoc?.email || requesterSnapshot?.email || null
|
||||
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
|
||||
if (email) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||
to: email,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName,
|
||||
ticketId: String(ticketId),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3373,38 +3526,6 @@ export const changeAssignee = mutation({
|
|||
createdAt: now,
|
||||
});
|
||||
|
||||
if (normalizedReason.length > 0) {
|
||||
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
||||
previousName: previousAssigneeName,
|
||||
nextName: nextAssigneeName,
|
||||
})
|
||||
const commentPlainLength = plainTextLength(commentBody)
|
||||
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||
}
|
||||
const authorSnapshot: CommentAuthorSnapshot = {
|
||||
name: viewerUser.name,
|
||||
email: viewerUser.email,
|
||||
avatarUrl: viewerUser.avatarUrl ?? undefined,
|
||||
teams: viewerUser.teams ?? undefined,
|
||||
}
|
||||
await ctx.db.insert("ticketComments", {
|
||||
ticketId,
|
||||
authorId: actorId,
|
||||
visibility: "INTERNAL",
|
||||
body: commentBody,
|
||||
authorSnapshot,
|
||||
attachments: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "COMMENT_ADDED",
|
||||
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -3734,6 +3855,8 @@ export const postChatMessage = mutation({
|
|||
await ctx.db.patch(ticketId, { updatedAt: now })
|
||||
|
||||
// Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
|
||||
// IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions
|
||||
// O Convex faz retry automatico em caso de OCC conflict
|
||||
const actorRole = participant.role?.toUpperCase() ?? ""
|
||||
if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
|
||||
const activeSession = await ctx.db
|
||||
|
|
@ -3743,10 +3866,15 @@ export const postChatMessage = mutation({
|
|||
.first()
|
||||
|
||||
if (activeSession) {
|
||||
await ctx.db.patch(activeSession._id, {
|
||||
unreadByMachine: (activeSession.unreadByMachine ?? 0) + 1,
|
||||
lastActivityAt: now,
|
||||
})
|
||||
// Refetch para garantir valor mais recente (OCC protection)
|
||||
const freshSession = await ctx.db.get(activeSession._id)
|
||||
if (freshSession) {
|
||||
await ctx.db.patch(activeSession._id, {
|
||||
unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1,
|
||||
lastActivityAt: now,
|
||||
lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,6 +279,86 @@ export const deleteUser = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualiza o avatar de um usuário.
|
||||
* Passa avatarUrl como null para remover o avatar.
|
||||
* Também atualiza os snapshots em comentários e tickets.
|
||||
*/
|
||||
export const updateAvatar = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
email: v.string(),
|
||||
avatarUrl: v.union(v.string(), v.null()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, email, avatarUrl }) => {
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||
.first()
|
||||
|
||||
if (!user) {
|
||||
return { status: "not_found" }
|
||||
}
|
||||
|
||||
// Atualiza o avatar do usuário - usa undefined para remover o campo
|
||||
const normalizedAvatarUrl = avatarUrl ?? undefined
|
||||
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
|
||||
|
||||
// Cria snapshot base sem avatarUrl se for undefined
|
||||
// Isso garante que o campo seja realmente removido do snapshot
|
||||
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
}
|
||||
if (normalizedAvatarUrl !== undefined) {
|
||||
baseSnapshot.avatarUrl = normalizedAvatarUrl
|
||||
}
|
||||
if (user.teams && user.teams.length > 0) {
|
||||
baseSnapshot.teams = user.teams
|
||||
}
|
||||
|
||||
// Atualiza snapshots em comentários
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_author", (q) => q.eq("authorId", user._id))
|
||||
.take(10000)
|
||||
|
||||
if (comments.length > 0) {
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Atualiza snapshots de requester em tickets
|
||||
const requesterTickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", user.tenantId).eq("requesterId", user._id))
|
||||
.take(10000)
|
||||
|
||||
if (requesterTickets.length > 0) {
|
||||
for (const t of requesterTickets) {
|
||||
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
|
||||
}
|
||||
}
|
||||
|
||||
// Atualiza snapshots de assignee em tickets
|
||||
const assigneeTickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", user._id))
|
||||
.take(10000)
|
||||
|
||||
if (assigneeTickets.length > 0) {
|
||||
for (const t of assigneeTickets) {
|
||||
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "updated", avatarUrl: normalizedAvatarUrl }
|
||||
},
|
||||
})
|
||||
|
||||
export const assignCompany = mutation({
|
||||
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Deploy Manual via VPS
|
||||
|
||||
## Acesso rápido
|
||||
- Host: 31.220.78.20
|
||||
- Host: 154.12.253.40
|
||||
- Usuário: root
|
||||
- Caminho do projeto: /srv/apps/sistema
|
||||
- Chave SSH (local): ./codex_ed25519 (chmod 600)
|
||||
- Login: `ssh -i ./codex_ed25519 root@31.220.78.20`
|
||||
- Login: `ssh -i ./codex_ed25519 root@154.12.253.40`
|
||||
|
||||
## Passo a passo resumido
|
||||
1. Conectar na VPS usando o comando acima.
|
||||
|
|
|
|||
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.
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
|
|||
|
||||
- **Bun (runtime padrão)**: 1.3+ já instalado no runner e VPS (`bun --version`). Após instalar localmente, exporte `PATH="$HOME/.bun/bin:$PATH"` para tornar o binário disponível. Use `bun install`, `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun test` como fluxo principal (scripts Node continuam disponíveis como fallback).
|
||||
- **Node.js**: mantenha a versão 20.9+ instalada para ferramentas auxiliares (Prisma CLI, scripts legados em Node) quando não estiver usando o runtime do Bun.
|
||||
- **Next.js 16**: Projeto roda em `next@16.0.8` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
|
||||
- **Next.js 16**: Projeto roda em `next@16.0.10` com Turbopack como bundler padrão (dev e build); webpack continua disponível como fallback.
|
||||
- **Lint/Test/Build**: `bun run lint`, `bun test`, `bun run build:bun`. O test runner do Bun já roda em modo não interativo; utilize `bunx vitest --watch` apenas quando precisar do modo watch manualmente.
|
||||
- **Banco DEV**: PostgreSQL local (Docker recomendado). Defina `DATABASE_URL` apontando para seu PostgreSQL.
|
||||
- **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases.
|
||||
|
|
@ -47,7 +47,7 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
|
|||
|
||||
## Next.js 16 (estável)
|
||||
|
||||
- Mantemos o projeto em `next@16.0.8`, com React 19 e o App Router completo.
|
||||
- Mantemos o projeto em `next@16.0.10`, com React 19 e o App Router completo.
|
||||
- **Bundlers**: Turbopack permanece habilitado no `next dev`/`bun run dev:bun` e agora também no `next build --turbopack`. Use `next build --webpack` somente para reproduzir bugs ou comparar saídas.
|
||||
- **Whitelist de hosts**: o release estável continua sem aceitar `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`.
|
||||
|
||||
|
|
@ -200,8 +200,8 @@ PY
|
|||
|
||||
## Referências úteis
|
||||
|
||||
- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`.
|
||||
- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`.
|
||||
- **Deploy (Swarm)**: veja `docs/OPERATIONS.md`.
|
||||
- **Plano do agente desktop / heartbeat**: `docs/archive/plano-app-desktop-dispositivos.md`.
|
||||
- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`.
|
||||
|
||||
> Última revisão: 18/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem.
|
||||
|
|
|
|||
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.
|
||||
|
||||
## Visão Geral
|
||||
- **Desenvolvimento local**: `docs/LOCAL-DEV.md` (setup rapido para rodar localmente)
|
||||
- Operações (produção): `docs/operations.md`
|
||||
- Guia de desenvolvimento: `docs/DEV.md`
|
||||
- Desktop (Tauri):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
|
|||
- Export/backup local de tickets: endpoint `POST /api/admin/tickets/archive-local` (staff) grava tickets resolvidos mais antigos que N dias em JSONL dentro de `ARCHIVE_DIR` (padrão `./archives`). Usa `exportResolvedTicketsToDisk` com segredo interno (`INTERNAL_HEALTH_TOKEN`/`REPORTS_CRON_SECRET`).
|
||||
|
||||
## Como acessar tickets antigos sem perda
|
||||
- Base quente: Prisma (SQLite) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
|
||||
- Base quente: Prisma (PostgreSQL) guarda todos os tickets; nenhuma rotina remove ou trunca tickets.
|
||||
- Se um dia for preciso offload (ex.: >50k tickets):
|
||||
- Exportar em lotes (ex.: JSONL mensais) para storage frio (S3/compat).
|
||||
- Gravar um marcador de offload no DB quente (ex.: `ticket_archived_at`, `archive_key`).
|
||||
|
|
@ -33,7 +33,7 @@ Estrategia: nenhuma limpeza automatica ligada. Usamos apenas monitoramento e, se
|
|||
## Checks operacionais sugeridos (manuais)
|
||||
- Tamanho do banco do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "ls -lh /var/lib/docker/volumes/sistema_convex_data/_data/db.sqlite3"`
|
||||
- Memoria do Convex: `ssh -i ~/.ssh/codex_ed25519 root@154.12.253.40 "docker stats --no-stream | grep convex"`
|
||||
- Alvos: <100-200 MB para o SQLite e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
|
||||
- Alvos: <100-200 MB para o SQLite do Convex e <5 GB de RAM. Acima disso, abrir janela curta, fazer backup e avaliar limpeza ou arquivamento pontual.
|
||||
|
||||
## Estado atual e proximos passos
|
||||
- Cron de limpeza segue desativado. Prioridade: monitorar 2-4 semanas para validar estabilidade pos-correcoes.
|
||||
|
|
|
|||
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
|
||||
|
||||
Nomes usados pelo sistema (conforme `src/lib/env.ts`):
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.c.inova.com.br
|
||||
SMTP_ADDRESS=smtp.c.inova.com.br
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=envio@rever.com.br
|
||||
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
|
||||
SMTP_FROM_NAME=Sistema de Chamados
|
||||
SMTP_FROM_EMAIL=envio@rever.com.br
|
||||
SMTP_TLS=false
|
||||
SMTP_ENABLE_STARTTLS_AUTO=true
|
||||
SMTP_USERNAME=envio@rever.com.br
|
||||
SMTP_PASSWORD=CAAJQm6ZT6AUdhXRTDYu
|
||||
SMTP_DOMAIN=rever.com.br
|
||||
MAILER_SENDER_EMAIL=Sistema de Chamados <envio@rever.com.br>
|
||||
```
|
||||
|
||||
## 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`
|
||||
- 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
|
||||
}
|
||||
|
||||
export type TicketCardProps = {
|
||||
ticketNumber: string
|
||||
ticketTitle: string
|
||||
status?: string | null
|
||||
priority?: string | null
|
||||
category?: string | null
|
||||
subcategory?: string | null
|
||||
companyName?: string | null
|
||||
requesterName?: string | null
|
||||
assigneeName?: string | null
|
||||
}
|
||||
|
||||
function badge(label: string, bg: string, color: string) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -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 (
|
||||
<Section
|
||||
style={{
|
||||
|
|
@ -100,3 +113,90 @@ export function TicketCard({ ticket }: { ticket: TicketCardData }) {
|
|||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketCard(props: TicketCardProps) {
|
||||
const { ticketNumber, ticketTitle, status, priority, category, subcategory, companyName, requesterName, assigneeName } = props
|
||||
const categoryLabel = category && subcategory ? `${category} / ${subcategory}` : category ?? subcategory ?? null
|
||||
|
||||
return (
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: "#f8fafc",
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||
margin: "24px 0",
|
||||
}}
|
||||
>
|
||||
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: "16px 20px", borderBottom: "1px solid #f1f5f9" }}>
|
||||
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||
Chamado #{ticketNumber}
|
||||
</Text>
|
||||
<Text style={{ margin: "4px 0 0 0", fontSize: "16px", fontWeight: 700, color: EMAIL_COLORS.textPrimary }}>
|
||||
{ticketTitle}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: "16px 20px" }}>
|
||||
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
{status ? (
|
||||
<tr>
|
||||
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Status
|
||||
</td>
|
||||
<td style={{ paddingBottom: "10px" }}>{statusBadge(status)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{priority ? (
|
||||
<tr>
|
||||
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Prioridade
|
||||
</td>
|
||||
<td style={{ paddingBottom: "10px" }}>{priorityBadge(priority)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{categoryLabel ? (
|
||||
<tr>
|
||||
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Categoria
|
||||
</td>
|
||||
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{categoryLabel}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{companyName ? (
|
||||
<tr>
|
||||
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Empresa
|
||||
</td>
|
||||
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{companyName}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{requesterName ? (
|
||||
<tr>
|
||||
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Solicitante
|
||||
</td>
|
||||
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{requesterName}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{assigneeName ? (
|
||||
<tr>
|
||||
<td style={{ width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||
Responsavel
|
||||
</td>
|
||||
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{assigneeName}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Button, Heading, Hr, Section, Text } from "@react-email/components"
|
|||
|
||||
import { RavenEmailLayout } from "./_components/layout"
|
||||
import { EMAIL_COLORS } from "./_components/tokens"
|
||||
import { TicketCard, type TicketCardData } from "./_components/ticket-card"
|
||||
import { TicketCardLegacy, type TicketCardData } from "./_components/ticket-card"
|
||||
import { normalizeTextToParagraphs } from "./_components/utils"
|
||||
|
||||
export type AutomationEmailProps = {
|
||||
|
|
@ -37,7 +37,7 @@ export default function AutomationEmail(props: AutomationEmailProps) {
|
|||
</Text>
|
||||
)}
|
||||
|
||||
<TicketCard ticket={props.ticket} />
|
||||
<TicketCardLegacy ticket={props.ticket} />
|
||||
|
||||
<Section style={{ marginTop: "18px" }}>
|
||||
<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/**",
|
||||
"next-env.d.ts",
|
||||
"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-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
|
|||
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.
|
||||
// Run scripts that rely on it via a transpiling runner (e.g. `tsx` or Bun).
|
||||
import { PrismaClient } from "../../src/generated/prisma/client.ts"
|
||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"
|
||||
import { PrismaPg } from "@prisma/adapter-pg"
|
||||
|
||||
const PROJECT_ROOT = process.cwd()
|
||||
const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma")
|
||||
|
||||
function resolveFileUrl(url) {
|
||||
if (!url.startsWith("file:")) {
|
||||
return url
|
||||
}
|
||||
|
||||
const filePath = url.slice("file:".length)
|
||||
|
||||
if (filePath.startsWith("//")) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return `file:${path.normalize(filePath)}`
|
||||
}
|
||||
|
||||
const normalized = path.normalize(filePath)
|
||||
const prismaPrefix = `prisma${path.sep}`
|
||||
const relativeToPrisma = normalized.startsWith(prismaPrefix)
|
||||
? normalized.slice(prismaPrefix.length)
|
||||
: normalized
|
||||
|
||||
const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma)
|
||||
|
||||
if (!absolutePath.startsWith(PROJECT_ROOT)) {
|
||||
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
|
||||
}
|
||||
|
||||
return `file:${absolutePath}`
|
||||
}
|
||||
|
||||
function normalizeDatasourceUrl(envUrl) {
|
||||
const trimmed = envUrl?.trim()
|
||||
if (trimmed) {
|
||||
return resolveFileUrl(trimmed)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return "file:/app/data/db.sqlite"
|
||||
}
|
||||
|
||||
return resolveFileUrl("file:./db.dev.sqlite")
|
||||
}
|
||||
const { Pool } = pg
|
||||
|
||||
export function createPrismaClient() {
|
||||
const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL)
|
||||
process.env.DATABASE_URL = resolvedDatabaseUrl
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
const adapter = new PrismaBetterSqlite3({
|
||||
url: resolvedDatabaseUrl,
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL environment variable is required")
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
})
|
||||
|
||||
const adapter = new PrismaPg(pool)
|
||||
|
||||
return new PrismaClient({ adapter })
|
||||
}
|
||||
|
|
|
|||
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 { env } from "@/lib/env"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
||||
import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
||||
import { notifyUserInvite } from "@/server/notification/notification-service"
|
||||
|
||||
const DEFAULT_EXPIRATION_DAYS = 7
|
||||
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
|
||||
|
|
@ -27,6 +28,17 @@ function normalizeRole(input: string | null | undefined): RoleOption {
|
|||
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "Administrador",
|
||||
manager: "Gestor",
|
||||
agent: "Agente",
|
||||
collaborator: "Colaborador",
|
||||
}
|
||||
|
||||
function formatRoleName(role: string): string {
|
||||
return ROLE_LABELS[role.toLowerCase()] ?? role
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return randomBytes(32).toString("hex")
|
||||
}
|
||||
|
|
@ -213,5 +225,24 @@ export async function POST(request: Request) {
|
|||
const normalized = buildInvitePayload(inviteWithEvents, now)
|
||||
await syncInviteWithConvex(normalized)
|
||||
|
||||
// Envia email de convite
|
||||
const inviteUrl = buildInviteUrl(token)
|
||||
const inviterName = session.user.name ?? session.user.email
|
||||
const roleName = formatRoleName(role)
|
||||
|
||||
try {
|
||||
await notifyUserInvite(
|
||||
email,
|
||||
name ?? null,
|
||||
inviterName,
|
||||
roleName,
|
||||
null, // companyName - não temos essa informação no convite
|
||||
inviteUrl
|
||||
)
|
||||
} catch (error) {
|
||||
// Log do erro mas não falha a criação do convite
|
||||
console.error("[invites] Falha ao enviar email de convite:", error)
|
||||
}
|
||||
|
||||
return NextResponse.json({ invite: normalized })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
|||
})
|
||||
|
||||
const createdDomainUser = await tx.user.upsert({
|
||||
where: { email },
|
||||
where: { id: createdAuthUser.id },
|
||||
update: {
|
||||
name,
|
||||
role: userRole,
|
||||
|
|
@ -213,6 +213,7 @@ export async function POST(request: Request) {
|
|||
managerId: managerRecord?.id ?? null,
|
||||
},
|
||||
create: {
|
||||
id: createdAuthUser.id,
|
||||
name,
|
||||
email,
|
||||
role: userRole,
|
||||
|
|
|
|||
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