ArboreoLab Project - AI Coding Agent Instructions
📋 Project Overview
Clio is a digital archive management system for historical documents, integrating:
- Vue.js 3 Frontend (
iface-frontend-vuejs/) - The main UI, built with TypeScript and Vite. - Node.js/Express Backend (
node/) - A REST API server that acts as a gateway and orchestrator. - Python Processing Scripts (
estudos/) - A collection of scripts for heavy-lifting tasks like OCR, NER, semantic search, and RAG. - Legacy PHP Endpoints (
Endpoint-Geopoliticas/) - Primarily for WordPress/Tainacan integration, referenced by the Node.js backend.
Architecture Summary
The system follows a microservices-like pattern where the Node.js backend orchestrates calls to Python scripts and external APIs.
┌─────────────────────────────────────────────────────────────┐
│ Frontend (Vue.js 3) │
│ (iface-frontend-vuejs) │
│ Served by Nginx on srv1.arboreolab.com.br │
└──────────────────────────┬──────────────────────────────────┘
│ HTTP Requests (/api/*)
┌──────────────────────────▼──────────────────────────────────┐
│ Node.js Backend (Express) │
│ (node/app.js) │
│ Runs on Port 3000, managed by PM2 │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────┼──────────────────────┬──────────────────────┐
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ MySQL │ │ WordPress API │ │ Google APIs │ │ Python Scripts │
│ (MariaDB) │ │ (Tainacan) │ │ (Drive, Gemini) │ │ (estudos/) │
│ `ClioVector`│ │ │ │ │ │ (via child_process)│
└─────────────┘ └─────────────────┘ └──────────────────┘ └──────────────────┘
🔧 Technology Stack & Key Files
Frontend (iface-frontend-vuejs/)
- Framework: Vue.js 3 with Composition API (
<script setup lang="ts">). - Build Tool: Vite.
- State Management: Pinia (
src/stores/). - HTTP Client: Axios (
src/api/axios.ts). - Key Components:
src/views/Gerenciamentos/GerenciadorDocs.vue: The central hub for all management tasks.src/views/Projeto/GerenciadorProjeto.vue: Project dashboard with stats, connections (Google Drive, Tainacan), team management, and activity logs.src/views/Projeto/WizardInstalacao.vue: 5-step installation wizard for new projects.src/components/gerenciadordedados/gerenciadordedados.vue: Manages raw data records and Google Drive integration.src/components/gerenciadordedados/gerenciadordeocr.vue: OCR viewer component with multi-format text extraction and integrity checking.src/components/gerenciadordocumentos/gerenciadormeta.vue: The main interface for editing document metadata.src/components/gerenciadordocumentos/MetadataEditor.vue: The form for editing NOBRADE-compliant metadata fields.src/components/gerenciadordearquivos/gerenciadordearquivos.vue: File explorer for Google Drive with workflow integration.src/components/gerenciadordearquivos/gerenciadortratamentopdf.vue: PDF treatment module - converts images to PDF/A with XMP metadata (e-ARQ Brasil).src/components/gerenciadordearquivos/propriedades.vue: Properties panel for files (cloud, data, metadata).src/components/gerenciadordearquivos/entidadeserelacioinamentos.vue: Entity and relationship viewer component.src/views/EntidadeRelacionamentos/EntidadesRelacionamentos.vue: View for searching entities and relationships.src/views/Armazenamento/Armazenamento.vue: Storage/Archive view with file manager.
Backend (node/)
- Framework: Express.js.
- Entry Point:
app.js. - Authentication: JWT-based.
backend/middleware/verifyJwt.jsprotects routes.backend/API/googleAuthEndpoint.jshandles Google Sign-In and issues JWTs. - Database Client:
mysql2. - Key Routes:
backend/routes/gerenciadorDadosRoute.js: CRUD for documents before metadata stage. Includes entity search and OCR endpoints.backend/routes/gerenciadorMetaRoute.js: CRUD for document metadata.backend/routes/fregeRAGRoute.js: Proxies requests to Python scripts for AI/RAG tasks by spawning child processes.backend/routes/geopoliticasProxy.js&proxyRoute.js: Forward requests to the external WordPress/Tainacan API.backend/routes/projetoRoute.js: Multi-tenant project management, team invites, user profile. Includes endpoints for/api/projeto/*.backend/routes/pdfTreatmentRoute.js: PDF conversion from images with XMP metadata (e-ARQ Brasil). Endpoints:/api/pdf-treatment/*.
Python Services (estudos/)
- Virtual Environment:
/home/arboreolab/estudos/.venv/. Must be activated (source .../activate) before running any script. - Configuration: Reads from
.inifiles inestudos/1_funcionais/fregeRAG_v1/config/. - Key Scripts:
1_funcionais/fregeRAG_v1/1_busca/fregeV2+busca_entidades.py: Main script for semantic search, called by the Node.js backend.1_funcionais/fregeRAG_v1/gerenciador_drive/GerenciadorDriveGoogle.py: CLI script for Google Drive operations (sync, auth, upload). Called bygerenciadorDriveGoogle.js.1_funcionais/fregeRAG_v1/gerar_documento/ocr_cluster_segmentadov1v2v3.py: Performs OCR using Google Gemini.1_funcionais/fregeRAG_v1/gerar_entidades/gerarEntespagina.py: Performs Named Entity Recognition (NER).1_funcionais/fregeRAG_v1/correcao_entidades/cluster_entities.py: Groups similar entities together.
⚠️ ATENÇÃO: O script
fregeRAG.pyé um menu interativo e NÃO DEVE ser chamado pelo backend (contémos.system("cls")que falha em Linux).
🎯 Critical Conventions & Patterns
1. Custom INI Configuration Parser
Problem: The standard ini library treats # as a comment, which breaks database passwords.
Solution: A custom INI parser function, parseConfigFile, is defined and used in multiple backend routes (e.g., gerenciadorDadosRoute.js, gerenciadorMetaRoute.js). This function correctly handles special characters in values.
Example from gerenciadorDadosRoute.js:
// ...
const parseConfigFile = (filePath) => {
// ... custom implementation that splits on the first '='
}
async function getConnection() {
const configPath = path.join(/*...*/, '.config.cfg');
const config = parseConfigFile(configPath);
// ... use config to connect
}
2. Tainacan Metadata Mapping & Tag Separators
Problem: Tainacan uses | as a tag separator, but names can contain commas (e.g., "Di Cavalcanti, Emiliano").
Convention:
- Tainacan API: Uses
|(pipe) as the separator. - Internal Format (UI/DB): Uses
;(semicolon) as the separator. - NEVER use a comma (
,) to split tags.
The service iface-frontend-vuejs/src/services/tainacanMetadataMapper.ts contains all logic for converting between these formats.
Example from tainacanMetadataMapper.ts:
// ...
export function stringToTagsArray(tagString: string | null | undefined): string[] {
if (!tagString) return [];
// Auto-detects separator
const separator = tagString.includes('|') ? '|' : ';';
return tagString.split(separator).map(t => t.trim()).filter(Boolean);
}
// ...
3. Backend as an Orchestrator
The Node.js backend does not perform heavy computations. Instead, it spawns Python scripts for tasks like search, OCR, and NER.
Example from fregeRAGRoute.js:
// ...
const pythonProcess = spawn(PYTHON_PATH, args, {
cwd: FREGE_DIR,
// ...
});
pythonProcess.stdout.on('data', (data) => {
// Process streaming output from the Python script
});
// ...
Google Drive Configuration (gerenciadorDriveGoogle.js):
⚠️ CRITICAL: The script configuration must point to the correct CLI script, NOT the interactive menu.
// filepath: node/backend/routes/gerenciadorDriveGoogle.js
// ✅ CORRECT Configuration
const PYTHON_EXECUTABLE = '/home/arboreolab/estudos/.venv/bin/python';
const PYTHON_SCRIPT_DIR = '/home/arboreolab/estudos/1_funcionais/fregeRAG_v1/gerenciador_drive';
const PYTHON_SCRIPT_FILE = 'GerenciadorDriveGoogle.py'; // CLI script
// ❌ WRONG - Do NOT use fregeRAG.py (interactive menu with os.system("cls"))
// const PYTHON_SCRIPT_FILE = 'fregeRAG.py'; // Will fail on Linux!
Python Script Commands (GerenciadorDriveGoogle.py):
| Command | Arguments | Description |
|---|---|---|
check_auth | --email | Verify OAuth tokens for user |
map | --email | Full sync of Drive structure |
map_folder | --email --folder_id | Sync specific folder only |
upload_file | --email --file_path --parent_id | Upload file to Drive |
create_folder | --email --name --parent_id | Create folder in Drive |
delete_item | --email --file_id | Delete file/folder |
move_item | --email --file_id --new_parent_id | Move file to new folder |
Generated Files:
estudos/1_funcionais/fregeRAG_v1/gerenciador_drive/GoogleDrive/{email}/
├── token.json # OAuth refresh token
├── drive_structure.json # Full tree structure (~72MB for large acervos)
└── drive_stats.json # Summary statistics
4. API Path Proxying
Problem: To avoid CORS issues, the frontend makes all API calls to its own origin (e.g., /api/...).
Solution: The development server (vite.config.ts) and production server (nginx) proxy these requests.
/api/*is proxied to the Node.js backend on port 3000./api/proxy/tainacan/*is proxied to the WordPress/Tainacan server via Node.js backend.
Example from iface-frontend-vuejs/src/api/axios.ts:
// ...
const getBaseUrl = () => {
// ... logic to determine base URL
if (/* in production or on arboreolab.com.br */) {
return ''; // Use relative paths like '/api/...'
}
return 'http://localhost:3000'; // For local dev
};
const axiosInstance = axios.create({
baseURL: getBaseUrl(),
// ...
});
// ...
5. WordPress/Tainacan Proxy Configuration
Problem: Users may connect to different Tainacan/WordPress servers. Direct frontend calls to external WordPress APIs cause CORS errors and expose credentials.
Solution: All Tainacan API calls are routed through the Node.js backend proxy at /api/proxy/tainacan/*.
Backend Configuration (node/backend/routes/proxyRoute.js)
The proxy route handles authentication and forwards requests to the WordPress server:
const axios = require('axios');
const https = require('https');
const path = require('path');
// Load .env explicitly (important for PM2)
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
module.exports = function(app) {
const WP_API_URL = process.env.WP_BASE_URL
? `${process.env.WP_BASE_URL}/wp-json`
: 'https://tainacan.geopoliticasinstitucionais.com.br/wp-json';
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
const proxyRequest = async (req, res, apiNamespace) => {
const pathParam = req.params[0];
const targetUrl = `${WP_API_URL}/${apiNamespace}/${pathParam}`;
// Generate Basic Auth from environment variables
const wpUser = process.env.WP_ADMIN_USER;
const wpPass = process.env.WP_ADMIN_PASS;
const authString = Buffer.from(`${wpUser}:${wpPass}`).toString('base64');
const response = await axios({
method: req.method,
url: targetUrl,
params: req.query,
data: req.method !== 'GET' ? req.body : undefined,
headers: {
'Authorization': `Basic ${authString}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
httpsAgent,
timeout: 30000,
});
res.status(response.status).json(response.data);
};
// Tainacan API v2
app.use('/api/proxy/tainacan/*', (req, res) => proxyRequest(req, res, 'tainacan/v2'));
// Geopolíticas custom plugin
app.use('/api/proxy/geopoliticas/*', (req, res) => proxyRequest(req, res, 'geopoliticas/v1'));
};
Environment Variables (.env)
Configure the WordPress/Tainacan server credentials:
# WordPress/Tainacan Server
WP_BASE_URL=https://tainacan.geopoliticasinstitucionais.com.br
# WordPress Application Password (NOT the user's login password)
# Generate at: WordPress Admin → Users → Your Profile → Application Passwords
WP_ADMIN_USER=your.email@example.com
WP_ADMIN_PASS=xxxx xxxx xxxx xxxx xxxx xxxx
Important Notes:
WP_ADMIN_PASSmust be a WordPress Application Password, not the regular login password- Application Passwords use spaces as separators (e.g.,
x8xp F7bw bXre EmLc YOeC f3io) - To create an Application Password:
- Go to WordPress Admin → Users → Your Profile
- Scroll to "Application Passwords"
- Enter a name (e.g., "Clio API") and click "Add New"
- Copy the generated password immediately (it won't be shown again)
Frontend Usage
In Vue components, use the TAINACAN_API_BASE_URL constant for all Tainacan API calls:
// filepath: src/components/api_base.ts
export const TAINACAN_API_BASE_URL = '/api/proxy/tainacan';
// Usage in components
import { TAINACAN_API_BASE_URL } from '@/components/api_base'
import axiosInstance from '@/api/axios'
// ✅ Correct: Uses proxy
const response = await axiosInstance.get(`${TAINACAN_API_BASE_URL}/items/91348`)
// ❌ Wrong: Direct call causes CORS and returns HTML
const response = await axiosInstance.get('/wp-json/tainacan/v2/items/91348')
API Endpoints Available via Proxy
| Proxy Path | Target WordPress Path |
|---|---|
/api/proxy/tainacan/items/{id} | /wp-json/tainacan/v2/items/{id} |
/api/proxy/tainacan/collections | /wp-json/tainacan/v2/collections |
/api/proxy/tainacan/collection/{id}/items | /wp-json/tainacan/v2/collection/{id}/items |
/api/proxy/tainacan/collection/{id}/metadata | /wp-json/tainacan/v2/collection/{id}/metadata |
/api/proxy/tainacan/taxonomy/{id}/terms | /wp-json/tainacan/v2/taxonomy/{id}/terms |
Debugging the Proxy
-
Check if credentials are loaded:
curl "http://localhost:3000/api/proxy/debug"Expected response:
{
"wp_user_configured": true,
"wp_pass_configured": true,
"wp_base_url": "https://tainacan.geopoliticasinstitucionais.com.br",
"api_url": "https://tainacan.geopoliticasinstitucionais.com.br/wp-json"
} -
Test a Tainacan request:
curl "http://localhost:3000/api/proxy/tainacan/items/91348" -
Common errors:
- 403 Forbidden: Application Password invalid or expired. Create a new one.
- HTML response instead of JSON: Proxy not configured, request going directly to frontend.
- ECONNREFUSED: WordPress server unreachable. Check
WP_BASE_URL.
-
After changing
.env, always restart with:pm2 restart node-backend-Arboreolab --update-env
Multi-User/Multi-Server Support (Future)
For scenarios where different users connect to different Tainacan servers:
- Store user's Tainacan credentials in their profile (encrypted)
- Pass the target server URL as a header or parameter
- Modify
proxyRoute.jsto read user-specific credentials from database
// Example future implementation
app.use('/api/proxy/tainacan/*', async (req, res) => {
const userEmail = req.headers['x-user-email'];
const userCredentials = await getUserTainacanCredentials(userEmail);
if (!userCredentials) {
return res.status(401).json({ error: 'Tainacan não configurado para este usuário' });
}
// Use user-specific credentials
const targetUrl = `${userCredentials.wpBaseUrl}/wp-json/tainacan/v2/${req.params[0]}`;
// ... rest of proxy logic
});
5. Entity Search and Relationship System
The clio_entidades table stores named entities extracted from documents. Key patterns:
Table Structure:
inFileID: Can be a single value (123) or comma-separated (16812,16811)- JSON fields:
entity_name,isA,relatedTo_entity_name,relationshipType,relatedTo_entity_isAstore JSON strings
Backend Query Pattern for Entity Search:
// Search by file ID (handles multiple formats)
const [entidades] = await connection.execute(`
SELECT * FROM clio_entidades
WHERE
inFileID = ?
OR inFileID LIKE CONCAT(?, ',%')
OR inFileID LIKE CONCAT('%,', ?)
OR inFileID LIKE CONCAT('%,', ?, ',%')
ORDER BY entity_name ASC
`, [fileId, fileId, fileId, fileId]);
// Parse JSON fields
const jsonFields = ['entity_name', 'isA', 'relatedTo_entity_name', 'relationshipType'];
jsonFields.forEach(field => {
if (processed[field]) {
try {
const parsed = JSON.parse(processed[field]);
processed[field] = Array.isArray(parsed) ? parsed[0] : parsed;
} catch { /* Keep original if not valid JSON */ }
}
});
6. Component Composition Pattern
Reusable components should support both internal state and prop-driven state:
Example from entidadeserelacioinamentos.vue:
const props = withDefaults(defineProps<{
dbId?: number | string | null // For internal API calls
entities?: EntityRecord[] // For external data
isLoading?: boolean // External loading state
error?: string | null // External error state
}>(), { ... })
// Use computed to choose between props and internal state
const displayEntities = computed(() => {
if (props.entities && props.entities.length > 0) {
return props.entities
}
return internalEntities.value
})
7. Modal Stacking with z-index
When opening modals from within modals, ensure proper z-index stacking:
<!-- Primary modal -->
<div v-if="propertiesModal.visible" class="modal" style="z-index: 1050;">
<!-- Secondary modal (opened from primary) -->
<div v-if="entitiesModal.visible" class="modal" style="z-index: 1060;">
<!-- Tertiary modal (opened from secondary) -->
<div v-if="ocrModal.visible" class="modal" style="z-index: 1070;">
### 8. CSS Container Queries for Responsive Components
Use container queries for components that need to adapt to their container size (not viewport):
```css
.propriedades-container {
container-type: inline-size;
container-name: propriedades;
}
@container propriedades (max-width: 800px) {
.config-grid {
grid-template-columns: 1fr 1fr;
}
}
@container propriedades (max-width: 600px) {
.config-grid {
grid-template-columns: 1fr;
}
}
9. OCR Content Multi-Format Parsing
The OCR system (Gemini) produces JSON in various formats. The gerenciadordeocr.vue component handles all of them:
| Format | Structure | Example |
|---|---|---|
| 1 | Array text | { "text": ["linha1", "linha2"] } |
| 2 | Columns left/right | { "left": [...], "right": [...] } |
| 3 | Transcription with metadata | { "transcricao_literal": "...", "metadados_documento": {...}, "elementos_nao_textuais": [...] } |
| 4 | Array of objects | [{ "text": "bloco1" }, { "text": "bloco2" }] |
| 5 | Simple string | "texto direto" |
| 6 | Object with text string | { "text": "texto único" } |
| 7 | Generic object with arrays | { "header": [...], "body": [...] } |
Example parsing logic from gerenciadordeocr.vue:
const extractedText = computed(() => {
// ...
for (const pagina of content.paginas) {
if (pagina.texto) {
try {
const parsed = JSON.parse(pagina.texto)
// Format 1: { "text": ["linha1", "linha2", ...] }
if (parsed.text && Array.isArray(parsed.text)) {
textParts.push(parsed.text.join('\n'))
}
// Format 2: { "left": [...], "right": [...] }
else if (parsed.left || parsed.right) {
// Handle columns...
}
// Format 3: { "transcricao_literal": "...", ... }
else if (parsed.transcricao_literal) {
textParts.push(parsed.transcricao_literal)
// Also extract metadados_documento, elementos_nao_textuais, analise_ocr...
}
// ... other formats
} catch {
textParts.push(pagina.texto) // Fallback to raw text
}
}
}
})
10. Data Integrity Verification Pattern
The system uses a 3-table integrity check to verify file consistency across:
clio_ocr(url_id)gerenciamento_googledrive(id_drive)gerenciador_dados(url_pdf or url_jpg)
Backend endpoint example:
router.get('/ocr/integrity/:urlId', async (req, res) => {
const { urlId } = req.params;
// Check in clio_ocr
const [ocrRows] = await connection.execute(`
SELECT id, workflow_id, name, url_id FROM clio_ocr WHERE url_id = ? LIMIT 1
`, [urlId]);
// Check in gerenciamento_googledrive
const [driveRows] = await connection.execute(`
SELECT id, id_drive, nome, web_view_link FROM gerenciamento_googledrive WHERE id_drive = ? LIMIT 1
`, [urlId]);
// Check in gerenciador_dados (url_pdf OR url_jpg)
const [dadosRows] = await connection.execute(`
SELECT db_id, conteudo_original_nome_arquivo, url_pdf, url_jpg
FROM gerenciador_dados WHERE url_pdf = ? OR url_jpg = ? LIMIT 1
`, [urlId, urlId]);
return {
found_in: {
clio_ocr: ocrRows.length > 0,
gerenciamento_googledrive: driveRows.length > 0,
gerenciador_dados: dadosRows.length > 0
},
integrity_ok: ocrRows.length > 0 && driveRows.length > 0 && dadosRows.length > 0,
// ...
};
});
Frontend visual status:
- ✅ Complete (3/3): All tables have the file
- ⚠️ Partial (2/3): File missing in one table
- ❌ Minimal (1/3): File only in one table
11. Team Display Pattern (Equipe do Projeto)
The GerenciadorProjeto.vue uses a role-based team display with hierarchical sorting and avatar colors.
Interface and State:
interface MembroEquipe {
id: number
usuario_id: number
email: string
nome: string | null
avatar_url: string | null
role: 'admin' | 'gestor' | 'usuario' | 'visitante'
status: 'pendente' | 'ativo' | 'suspenso' | 'removido'
isOwner?: boolean
}
const equipeMembros = ref<MembroEquipe[]>([])
const isLoadingEquipe = ref(false)
Role Hierarchy Sorting:
const equipeMembrosOrdenados = computed(() => {
const roleOrder: Record<string, number> = {
owner: 0, admin: 1, gestor: 2, usuario: 3, visitante: 4
}
return [...equipeMembros.value].sort((a, b) => {
const roleA = a.isOwner ? 'owner' : a.role
const roleB = b.isOwner ? 'owner' : b.role
return (roleOrder[roleA] ?? 99) - (roleOrder[roleB] ?? 99)
})
})
Role Color Classes:
| Role | CSS Class | Gradient |
|---|---|---|
owner | .role-owner | linear-gradient(135deg, #ffc107, #fd7e14) |
admin | .role-admin | linear-gradient(135deg, #6f42c1, #e83e8c) |
gestor | .role-gestor | linear-gradient(135deg, #007bff, #0056b3) |
usuario | .role-usuario | linear-gradient(135deg, #28a745, #20c997) |
visitante | .role-visitante | linear-gradient(135deg, #6c757d, #495057) |
Helper Functions:
function getRoleLabel(role: string, isOwner?: boolean): string {
if (isOwner) return 'Proprietário'
const labels: Record<string, string> = {
admin: 'Administrador', gestor: 'Gestor',
usuario: 'Usuário', visitante: 'Visitante'
}
return labels[role] || role
}
function getInitials(membro: MembroEquipe): string {
const name = membro.nome || membro.email || ''
return name.split(/[\s@]/).filter(Boolean).slice(0, 2)
.map(p => p.charAt(0).toUpperCase()).join('')
}
function getRoleColorClass(role: string, isOwner?: boolean): string {
if (isOwner) return 'role-owner'
return `role-${role}`
}
CSS for Avatar Placeholder:
.avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.avatar-placeholder.role-owner { background: linear-gradient(135deg, #ffc107, #fd7e14); }
.avatar-placeholder.role-admin { background: linear-gradient(135deg, #6f42c1, #e83e8c); }
.avatar-placeholder.role-gestor { background: linear-gradient(135deg, #007bff, #0056b3); }
.avatar-placeholder.role-usuario { background: linear-gradient(135deg, #28a745, #20c997); }
.avatar-placeholder.role-visitante { background: linear-gradient(135deg, #6c757d, #495057); }
🚀 Developer Workflows
Full System Startup (Local)
- Start Backend API:
cd /home/arboreolab/Clio/node
# Start with PM2 (recommended)
pm2 start app.js --name clio-backend
# Or run directly for debugging
node app.js - Start Frontend Dev Server:
Access the application at
cd /home/arboreolab/Clio/iface-frontend-vuejs
npm run devhttp://localhost:5173.
Procedimentos Rápidos (cheat-sheet)
Esta seção lista comandos e passos práticos para tarefas comuns no projeto Clio. Use-os como referência rápida; adapte caminhos/variáveis quando necessário.
- Reiniciar backend (modo produção com PM2):
cd /home/arboreolab/Clio/node
# Reinicia o processo e carrega variáveis do .env
pm2 restart node-backend-Arboreolab --update-env
pm2 logs node-backend-Arboreolab --lines 200
- Iniciar backend em modo debug (sem PM2):
cd /home/arboreolab/Clio/node
node app.js
# Ou, se preferir com nodemon
npx nodemon app.js
- Build frontend (Vite) e publicar para nginx (produção):
cd /home/arboreolab/Clio/iface-frontend-vuejs
# Instalar dependências (se necessário)
npm ci
# Build de produção
npm run build
# Ajustar permissões da pasta dist (ex.: www-data)
sudo chown -R $USER:www-data dist
sudo chmod -R 755 dist
# (Se houver script de deploy fornecido)
./deploy.sh
- Validar rota API localmente (curl):
# Health / debug endpoint
curl -sS http://localhost:3000/api/proxy/debug | jq .
# Testar um endpoint do gerenciador de dados (exemplo)
curl -sS "http://localhost:3000/api/gerenciador-dados/cluster/pdfs-orfaos?email=seu.email@exemplo.com" | jq .
- Executar um script Python do
estudos(ativar venv primeiro):
source /home/arboreolab/estudos/.venv/bin/activate
cd /home/arboreolab/estudos/1_funcionais/fregeRAG_v1
python 1_busca/fregeV2+busca_entidades.py --query "termo de busca"
deactivate
- Verificar arquivos estáticos servidos pelo nginx (se houver comportamento estranho):
# Checar se nginx está rodando
sudo systemctl status nginx --no-pager
# Mostrar últimos logs do nginx
sudo journalctl -u nginx --since "1 hour ago" --no-pager | tail -n 200
- Quick DB checks (MySQL) — usar credenciais de .env:
# Exemplo: listar 5 registros da tabela gerenciador_dados
mysql -u $MYSQL_ADMIN_USER -p -e "USE ClioVector; SELECT db_id, conteudo_original_nome_arquivo, codigo_de_referencia, arquivo_pdf_com_ocr FROM gerenciador_dados LIMIT 5;"
- Deploy backend + frontend (resumido):
- Atualizar código (git pull / commits).
- No backend: rebuild/restart PM2
cd /home/arboreolab/Clio/node
pm2 restart node-backend-Arboreolab --update-env
- No frontend: build e deploy
cd /home/arboreolab/Clio/iface-frontend-vuejs
npm ci && npm run build
sudo chown -R $USER:www-data dist
sudo chmod -R 755 dist
# Se houver script de deploy: ./deploy.sh
- Reiniciar nginx (se necessário):
sudo systemctl reload nginx
Checklist de Verificação Pós-Deploy
- Backend: Verificar logs PM2 por erros.
- Frontend: Abrir a aplicação e validar telas críticas (Gerenciador de Dados, OCR, Entidades).
- Integração: Testar 2-3 endpoints API com curl (ex.: /api/gerenciador-dados, /api/projeto/meu-perfil).
- Scripts Python: ativar venv e executar um script rápido para confirmar dependências.
Se algo falhar, cole os últimos logs do PM2 e do nginx antes de pedir ajuda.
Running Python Scripts
- ALWAYS activate the virtual environment first:
source /home/arboreolab/estudos/.venv/bin/activate - Run a specific script for testing:
cd /home/arboreolab/estudos/1_funcionais/fregeRAG_v1
python 1_busca/fregeV2+busca_entidades.py --query "some search term"
Deployment
Deploy Automático com Scripts
O projeto possui scripts de deploy prontos para uso:
Frontend (iface-frontend-vuejs/deploy.sh):
cd /home/arboreolab/Clio/iface-frontend-vuejs
./deploy.sh # Incrementa patch version (0.0.X)
./deploy.sh --versao # Permite escolher major ou minor
Este script:
- Incrementa automaticamente a versão no
index.html - Registra a alteração em
versionamento.log - Executa
npm run build-only - Ajusta permissões (755) na pasta
dist - Reinicia todos os processos PM2
- Reinicia o Nginx para limpar caches
Backend (iface-backend-vuejs/deploy.sh):
cd /home/arboreolab/Clio/iface-backend-vuejs
./deploy.sh
Este script:
- Executa
npm run builddo frontend legado - Copia arquivos para
/var/www/arboreolab/iface-backend - Ajusta permissões e proprietário (www-data)
- Reinicia o processo PM2
node-backend-Arboreolab - Exibe logs do PM2
Deploy Manual
-
Backend (PM2):
# Restart the backend after changes
pm2 restart node-backend-Arboreolab --update-env
# View logs
pm2 logs node-backend-Arboreolab -
Frontend (Build manual):
cd /home/arboreolab/Clio/iface-frontend-vuejs
npm run build
Adding New API Endpoints
When adding new endpoints to the backend:
- Add the route in the appropriate route file (e.g.,
gerenciadorDadosRoute.js) - Restart the backend to apply changes:
pm2 restart node-backend-Arboreolab --update-env - Test the endpoint before using in frontend:
curl "http://localhost:3000/api/your-endpoint?param=value"
🗄️ Database Schema Highlights
📚 Documentação Completa: Para configuração de performance, pooling e troubleshooting do MariaDB, consulte: Configuração do MariaDB
gerenciador_dados: The main table for documents. It tracks the workflow status (status_ocr,status_entidade,status_tainacan) and contains ametadadosJSON column for NOBRADE fields. Key fields for file linking:url_pdf,url_jpg(Google Drive file IDs).gerenciamento_googledrive: A cache of the Google Drive file structure, including file IDs (id_drive), names, hierarchicalbreadcrumbs, andweb_view_link.clio_ocr: Stores the text content extracted from each page of a document. Key fields:workflow_id: Links togerenciador_dados.db_idurl_id: Google Drive file ID (should matchid_driveandurl_pdf/url_jpg)content: JSON with OCR results in various formatscontent_markdown: Markdown version of the textocr_vector: VECTOR(768) for semantic searchengine_version: VARCHAR(50) DEFAULT 'legacy' — Motor OCR usado (legacy, vision-macos-v1, bert-ocr-v1)file_hash_sha256: CHAR(64) — Hash SHA256 para deduplicaçãoimg_width,img_height: INT — Dimensões da imagem original em pixelstechnical_metadata: LONGTEXT — JSON com metadados técnicos (EXIF, ISO, modelo câmera)qtde_caracteres: INT — Contagem de caracteres extraídos
clio_ocr_segments(🆕 Layout-Aware): Segmentos de texto com coordenadas espaciais para busca granular.ocr_id: FK paraclio_ocr.id(CASCADE DELETE)text_content: TEXT — Texto extraído do segmentosegment_vector: VECTOR(768) — Embedding do segmento para busca semânticabbox_x,bbox_y,bbox_w,bbox_h: FLOAT — Bounding box normalizado (0-1)confidence: FLOAT — Confiança do OCR (0.0 - 1.0)reading_order: INT — Ordem de leitura semântica
clio_entidades: A registry of all named entities (people, organizations, etc.) extracted from documents. Includes acluster_idfor grouping similar entities. Key fields:entity_name,isA: JSON strings for entity name and typeinFileID: Comma-separated list of document IDs where entity appearsrelatedTo_entity_name,relationshipType: Relationship datasemantic_vector: Vector embedding for semantic search
- Embeddings: Vector embeddings are stored in
.npyfiles (e.g.,ClioVector_clio_ocr_embeddings.npy), not in the database. The corresponding metadata is in.jsonfiles.
Tabelas de Administração Multi-Tenant (ClioVector)
-
arb_usuarios: Cadastro central de usuários do sistema.id,email(unique),nome,organizacao,telefone,avatar_urlgoogle_id: ID do Google OAuthstatus: ENUM('pendente','ativo','suspenso','inativo')role: ENUM('admin','gestor','usuario','visitante')email_verificado: TINYINT(1)
-
arb_projetos: Projetos/databases de cada usuário.id,usuario_id(FK → arb_usuarios.id),nome,slug,database_namestatus: ENUM('configurando','ativo','pausado','arquivado')storage_type: ENUM('local','google_drive','onedrive','icloud','s3')nuvem_status: Status de conexão com o storage
-
arb_projeto_usuarios: Membros da equipe de cada projeto (relacionamento N:N).id,projeto_id(FK),usuario_id(FK)role: ENUM('admin','gestor','usuario','visitante')permissao_leitura,permissao_escrita,permissao_exclusao,permissao_admin: TINYINT(1)status: ENUM('pendente','ativo','suspenso','removido')convidado_por: ID do usuário que convidouconvite_aceito_em: Timestamp de quando o convite foi aceito
-
arb_assinaturas: Planos e limites de uso por projeto.id,projeto_id(FK),plano,inicio,fim,statuslimite_documentos,limite_storage_gb,limite_ocr_mensal,limite_usuariosuso_documentos,uso_storage_gb,uso_ocr_mensal
-
arb_projeto_logs: Logs de auditoria de todas as operações.id,projeto_id(FK),usuario_id,acao,descricao,dados_anteriores,dados_novosacao: ENUM( 'projeto_criado', 'projeto_atualizado', 'projeto_arquivado', 'projeto_reativado', 'projeto_excluido', 'database_criado', 'database_backup', 'database_restore', 'storage_conectado', 'storage_desconectado', 'storage_sincronizado', 'storage_erro', 'tainacan_conectado', 'tainacan_sincronizado', 'tainacan_erro', 'credencial_adicionada', 'credencial_atualizada', 'credencial_removida', 'credencial_expirada', 'ocr_executado', 'ner_executado', 'embedding_gerado', 'usuario_login', 'usuario_logout', 'config_alterada', 'erro_sistema', 'arquivo_upload', 'arquivo_deletado', 'pasta_criada', 'pasta_deletada', 'arquivo_movido' )
Cross-Table Relationships (File Linking)
gerenciador_dados.url_pdf ──┐
gerenciador_dados.url_jpg ──┼──► Same Google Drive File ID
gerenciamento_googledrive.id_drive ──┤
clio_ocr.url_id ────────────┘
⚠️ Common Pitfalls
-
Python Environment Not Activated: Scripts fail with
ModuleNotFoundError. Always runsource /home/arboreolab/estudos/.venv/bin/activatefirst. -
Incorrect INI Parsing: Passwords with
#are truncated. Ensure any new code reading.inifiles uses the customparseConfigFilefunction. -
CORS Errors: Making API calls to
localhost:3000from the frontend will fail in production. Use relative paths (/api/...). -
Tag Splitting: Splitting tag fields by comma will break names. Use the
stringToTagsArrayutility in the frontend, which correctly handles|and;separators. -
Reactive State in Vue: When accessing Pinia store state in components, wrap it in
computed()to ensure reactivity, especially for values likeuserEmail.import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const userEmail = computed(() => authStore.userEmail); -
Environment Variables Not Loading (PM2/dotenv):
- Symptom: Logs show
⚠️ GOOGLE_CLIENT_ID não configurado no .enveven though the.envfile is correctly configured. - Cause: PM2 caches environment variables. When the
.envfile is updated, the running process doesn't automatically pick up the changes. Additionally,dotenv.config()must be called before any module that usesprocess.envis imported. - Solution:
- Force
dotenvto load early: In files that need environment variables (especially those not imported directly byapp.js), explicitly load the.envfile at the top:const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
// Now process.env.YOUR_VAR is available - Restart PM2 with
--update-env: After modifying the.envfile, always restart the process with this flag to force PM2 to reload the environment:pm2 restart node-backend-Arboreolab --update-env - Verify the path: Ensure the
path.resolve()correctly points to the.envfile relative to the current script's location (__dirname).
- Force
- Symptom: Logs show
-
404 Errors for New API Endpoints:
- Symptom: Frontend console shows
Failed to load resource: the server responded with a status of 404for a new endpoint. - Cause: The backend wasn't restarted after adding the new route.
- Solution: Always restart the backend after adding new routes:
pm2 restart node-backend-Arboreolab --update-env
- Symptom: Frontend console shows
-
TypeScript Build Errors - Missing Parentheses:
- Symptom: Build fails with
Unexpected token, expected ","error. - Cause: Missing closing parenthesis in TypeScript generic types, especially in
ref<>()declarations. - Example Error:
const uploadResults = ref<Array<{ fileName: string; success: boolean; message?: string }>>([ - Solution: Ensure all generic type declarations have matching parentheses:
const uploadResults = ref<Array<{ fileName: string; success: boolean; message?: string }>>([])
- Symptom: Build fails with
-
MySQL Connection Release:
- Symptom:
connection.release()throws error when usingmysql2.createConnection(). - Cause:
createConnection()returns a connection that uses.end(), not.release(). Only pool connections use.release(). - Solution: Use
.end()for direct connections:// For createConnection()
if (connection) await connection.end();
// For pool.getConnection()
if (connection) connection.release();
- Symptom:
-
Component Import Typos:
- Symptom: Component not rendering or build warnings.
- Cause: Typo in component filename (e.g.,
entidadeserelacioinamentos.vueinstead ofentidadeserelacionamentos.vue). - Solution: Be consistent with filenames. If file has a typo, either rename it or import with the exact typo.
-
SQL Syntax Error - Trailing Comma:
- Symptom:
ER_PARSE_ERRORwith message about syntax nearFROM. - Cause: Extra comma after the last column in SELECT statement.
- Example Error:
SELECT db_id, url_pdf, url_jpg, -- ❌ trailing comma
FROM gerenciador_dados - Solution: Remove the trailing comma:
SELECT db_id, url_pdf, url_jpg -- ✅ no trailing comma
FROM gerenciador_dados
- Symptom:
-
OCR Content Not Displaying:
- Symptom: OCR modal shows "Conteúdo não disponível" even though data exists.
- Cause: OCR JSON format not recognized by the parser.
- Solution: Check the
extractedTextcomputed ingerenciadordeocr.vueand add support for the new format. Common formats includetext[],left/right,transcricao_literal, etc.
-
Tainacan Taxonomy API - Invalid Parameters (400 Bad Request):
- Symptom:
GET /api/proxy/tainacan/taxonomy/{id}/termsreturns 400 Bad Request. - Cause: The Tainacan taxonomy terms endpoint does NOT accept
orderby=nameororder=ascparameters. - Invalid Request:
/taxonomy/11944/terms?perpage=1000&hideempty=0&orderby=name&order=asc → 400 Bad Request - Valid Parameters for
/taxonomy/{id}/terms:Parameter Valid Description perpage✅ Number of terms per page hideempty✅ 0 = show all, 1 = only with items paged✅ Page number search✅ Text search parent✅ Filter by parent term orderby❌ Not supported for terms order❌ Not supported for terms - Solution: Remove
orderbyandorderparameters, sort results locally in the frontend:const response = await axiosInstance.get(
`${TAINACAN_API_BASE_URL}/taxonomy/${taxonomyId}/terms`,
{ params: { perpage: 1000, hideempty: 0 } } // No orderby/order!
)
const terms = response.data.map((term: any) => ({
id: term.id,
name: term.name
}))
// Sort locally
terms.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR', { sensitivity: 'base' }))
- Symptom:
-
Tainacan Collections API - Invalid orderby Parameter:
- Symptom:
GET /api/proxy/tainacan/collectionswithorderby=namereturns error or unexpected results. - Cause: The Tainacan collections endpoint uses
titlenotnamefor ordering. - Solution: Use
orderby=titleinstead oforderby=name:// ❌ Wrong
const response = await axiosInstance.get(`${TAINACAN_API_BASE_URL}/collections`, {
params: { perpage: 100, orderby: 'name', order: 'asc' }
})
// ✅ Correct
const response = await axiosInstance.get(`${TAINACAN_API_BASE_URL}/collections`, {
params: { perpage: 100, orderby: 'title', order: 'asc' }
})
- Symptom:
-
Vue Build Error - Duplicate Function Declaration:
- Symptom: Build fails with
Identifier 'functionName' has already been declared. - Cause: A function was defined twice in the same
<script setup>block, often from copy-paste or incomplete refactoring. - Example Error:
[vue/compiler-sfc] Identifier 'loadTermsForTaxonomyMetadata' has already been declared. (2340:15) - Solution:
- Find all occurrences:
grep -n "function functionName" path/to/file.vue - Remove the duplicate declaration (usually keep the more complete version)
- Rebuild:
npm run build
- Find all occurrences:
- Symptom: Build fails with
-
TypeScript Unused Variable Warnings:
- Symptom: Build shows warnings like
'functionName' is declared but its value is never read.ts-plugin(6133) - Cause: Functions were declared for future use but not yet used in the template or other functions.
- Solutions:
- Option A: Use the functions in the template or other code
- Option B: Remove unused functions
- Option C: Prefix with underscore to indicate intentionally unused:
_unusedFunction
- Common cases in gerenciadormeta.vue:
Function Purpose Solution getFieldSuggestionsGet taxonomy terms for a field Use in template preview hasFieldSuggestionsCheck if field has suggestions Use for CSS class binding getFieldTaxonomyInfoGet taxonomy info for display Use in template clearDefaultCollectionClear saved collection Add button in UI
- Symptom: Build shows warnings like
-
Tainacan Taxonomy Sync - Terms Not Resolving:
- Symptom: Taxonomy fields show empty or fail to sync values to Tainacan.
- Cause: Tainacan taxonomy fields may require term IDs instead of term names.
- Solution: Implement a term resolution function that converts names to IDs:
async function resolveTermNamesToIds(taxonomyId: number, termNames: string[]): Promise<number[]> {
const response = await axiosInstance.get(
`${TAINACAN_API_BASE_URL}/taxonomy/${taxonomyId}/terms`,
{ params: { perpage: 1000, hideempty: 0 } }
)
const availableTerms = response.data.map((t: any) => ({
id: t.id,
name: t.name
}))
return termNames
.map(name => availableTerms.find(t =>
t.name.toLowerCase() === name.trim().toLowerCase()
)?.id)
.filter(Boolean)
}
-
Default Collection Pattern for Tainacan Integration:
- Context: Users frequently work with the same Tainacan collection. Remembering and loading the default collection improves UX.
- Implementation Pattern:
// Save default collection
function saveDefaultCollection(collectionId: number, collectionName: string) {
localStorage.setItem('tainacan_default_collection', JSON.stringify({
collectionId,
collectionName,
savedAt: new Date().toISOString()
}))
}
// Load on component mount
onMounted(() => {
const saved = localStorage.getItem('tainacan_default_collection')
if (saved) {
const config = JSON.parse(saved)
defaultCollectionId.value = config.collectionId
defaultCollectionName.value = config.collectionName
// Pre-load taxonomies for the form
loadCollectionTaxonomiesForForm(config.collectionId)
}
}) - Benefits:
- Faster form loading (taxonomies pre-cached)
- Consistent field suggestions across sessions
- Pre-selected collection in mapping tab
-
Query de Projetos do Usuário - Não Inclui Membros da Equipe:
- Symptom: Usuário convidado para um projeto não vê o projeto na página de Perfil.
- Cause: A query buscava apenas projetos onde
p.usuario_id = ?(proprietário), não incluindo projetos onde o usuário é membro viaarb_projeto_usuarios. - Solution: Modificar a query para incluir ambos os casos:
SELECT DISTINCT p.*,
CASE WHEN p.usuario_id = ? THEN 'owner' ELSE pu.role END as minha_role,
CASE WHEN p.usuario_id = ? THEN 1 ELSE 0 END as sou_proprietario
FROM arb_projetos p
LEFT JOIN arb_projeto_usuarios pu ON p.id = pu.projeto_id AND pu.usuario_id = ? AND pu.status = 'ativo'
WHERE p.usuario_id = ? OR (pu.usuario_id = ? AND pu.status = 'ativo') - Arquivo:
node/backend/routes/projetoRoute.js(endpoint/meu-perfil)
-
Nome de Coluna Incorreto na Tabela
arb_projeto_usuarios:- Symptom:
Unknown column 'pu.user_id' in 'WHERE' - Cause: A coluna se chama
usuario_id, nãouser_id. - Colunas corretas:
id,projeto_id,usuario_id,role,status,convidado_por,convite_aceito_em
- Symptom:
-
Plano de Assinatura Não Carrega Corretamente:
- Symptom: O plano na página inicial mostra "🆓 Gratuito" mesmo quando o projeto tem plano "enterprise".
- Cause: A API
/api/projeto/meu-perfilretorna o plano dentro do arrayprojetos, não como campoassinaturadireto. - Estrutura da resposta:
{
"success": true,
"usuario": { ... },
"projetos": [{ "plano": "enterprise", ... }]
} - Código incorreto:
plano: response.data.assinatura?.plano || 'gratuito' // ❌ Campo não existe - Código correto:
const primeiroProjeto = response.data.projetos?.[0]
plano: primeiroProjeto?.plano || 'gratuito' // ✅ - Arquivo:
inicio.vue(funçãoloadUserData)
-
Projetos de Convidados Não Aparecem na Página Inicial:
- Symptom: Usuário convidado para um projeto vê "Nenhum projeto" na coluna 3 da página inicial.
- Cause: O endpoint
/api/projeto/meus-projetossó buscava projetos onde o usuário é proprietário. - Solution: Modificar a query para incluir projetos onde o usuário é membro ativo:
// Primeiro buscar ID do usuário
const [usuarios] = await connection.execute(
'SELECT id FROM arb_usuarios WHERE email = ?',
[userEmail]
);
const usuarioId = usuarios[0].id;
// Depois buscar projetos (proprietário OU membro)
const [projetos] = await connection.execute(`
SELECT DISTINCT p.*, a.plano,
CASE WHEN p.usuario_id = ? THEN 'owner' ELSE pu.role END as minha_role,
CASE WHEN p.usuario_id = ? THEN 1 ELSE 0 END as sou_proprietario
FROM arb_projetos p
LEFT JOIN arb_projeto_usuarios pu ON p.id = pu.projeto_id AND pu.usuario_id = ? AND pu.status = 'ativo'
LEFT JOIN arb_assinaturas a ON p.id = a.projeto_id
WHERE p.usuario_id = ? OR (pu.usuario_id = ? AND pu.status = 'ativo')
ORDER BY sou_proprietario DESC, p.updated_at DESC
`, [usuarioId, usuarioId, usuarioId, usuarioId, usuarioId]); - Arquivo:
node/backend/routes/projetoRoute.js(endpoint/meus-projetos)
-
Database Hardcoded para Template em Vez de Governança (Multi-Tenant):
-
Symptom: Erros
Table 'ClioVector.arb_projetos' doesn't existouTable 'ClioVector.drive_notification_channels' doesn't existnos logs do PM2. -
Cause: Rotas do backend estavam usando
database: 'ClioVector'hardcoded para acessar tabelas de governança global (arb_*,drive_notification_channels), quando deveriam usarArboreolabADM. -
Contexto Multi-Tenant:
Database Função Tabelas ArboreolabADMGovernança Central arb_usuarios,arb_projetos,arb_projeto_usuarios,arb_assinaturas,arb_projeto_logs,drive_notification_channelsarboreolabconnOrquestração Workers worker_registry,job_queue,remote_logClioVectorModelo/Template Estrutura de tabelas canônica (todas as tabelas de projeto) {Nome}VectorProjeto Real Cópia do modelo + dados do usuário (ex: GeopoliticasVector) -
⚠️ REGRA CRÍTICA: Gerenciamento de Schema Multi-Tenant:
┌─────────────────────────────────────────────────────────────────┐
│ ClioVector é SEMPRE o MODELO CANÔNICO de schema │
│ ───────────────────────────────────────────────────────────── │
│ • Novos projetos são criados copiando a estrutura do modelo │
│ • Toda nova tabela deve ser adicionada PRIMEIRO ao ClioVector │
│ • Projetos existentes devem espelhar o schema do modelo │
│ • Testes/simulações: usar database real (GeopoliticasVector) │
└─────────────────────────────────────────────────────────────────┘Tabelas do Modelo ClioVector (15 tabelas - 2025-01-03):
Categoria Tabelas Core gerenciador_dados,gerenciador_metadados,gerenciador_projeto_credentialsGoogle Drive gerenciamento_googledriveOCR clio_ocr,clio_ocr_vector,clio_ocr_segments,clio_ocr_clustering_iterations,clio_ocr_cluster_rejectionsEntidades clio_entidades,clio_entidades_vectorPublicação publicacao_tainacan,publicacao_atomSistema sys_equipe,sys_arquivos_temporariosQuery de Verificação de Paridade:
-- Comparar tabelas entre modelo e projeto real
SELECT
m.table_name AS tabela_modelo,
p.table_name AS tabela_projeto,
CASE
WHEN m.table_name IS NULL THEN '⚠️ FALTA NO MODELO'
WHEN p.table_name IS NULL THEN '⚠️ FALTA NO PROJETO'
ELSE '✅ OK'
END AS status
FROM
(SELECT table_name FROM information_schema.tables
WHERE table_schema = 'ClioVector'
AND table_name NOT LIKE '%_backup%' AND table_name NOT LIKE 'clio_stats_%') m
FULL OUTER JOIN
(SELECT table_name FROM information_schema.tables
WHERE table_schema = 'GeopoliticasVector'
AND table_name NOT LIKE '%_backup%' AND table_name NOT LIKE 'clio_stats_%') p
ON m.table_name = p.table_name
WHERE m.table_name IS NULL OR p.table_name IS NULL; -
Solution: Definir constante para o banco de administração e usá-la em todas as queries de governança:
// No topo do arquivo de rota
const ADMIN_DATABASE = process.env.MYSQL_ADMIN_DATABASE || 'ArboreolabADM';
// Na conexão para tabelas de governança (arb_*, drive_notification_channels)
const adminConn = await mysql.createConnection({
host: config.database?.host || 'localhost',
port: parseInt(config.database?.port) || 3306,
user: config.database?.user,
password: config.database?.password,
database: ADMIN_DATABASE // ✅ Correto
// database: 'ClioVector' // ❌ Errado - template não tem tabelas de governança
}); -
Arquivos corrigidos (2025-12-31):
node/backend/routes/driveWebhook.js- 6 ocorrênciasnode/backend/routes/gerenciadorDriveGoogle.js- 2 ocorrências
-
Validação:
# Verificar se ainda há referências hardcoded
grep -rn "'ClioVector'" node/backend/routes/ --include="*.js"
# Testar endpoints após correção
curl -sS "http://localhost:3000/api/drive-webhook/status/adm.geopoliticas%40gmail.com"
curl -sS "http://localhost:3000/api/google-drive/collections?driveEmail=adm.geopoliticas%40gmail.com"
-
-
Wrong Python Script Called for Google Drive Operations:
- Symptom: Sync stuck at "Mapeando Google Drive..." (20%), PM2 logs show
sh: 1: cls: not found - Cause: Backend was calling
fregeRAG.py(interactive menu with Windows commands) instead ofGerenciadorDriveGoogle.py(CLI script for Linux) - Root Cause:
fregeRAG.pycontainsos.system("cls")which only works on Windows - Solution: Update constants in
gerenciadorDriveGoogle.js:// ✅ CORRECT Configuration
const PYTHON_SCRIPT_DIR = '/home/arboreolab/estudos/1_funcionais/fregeRAG_v1/gerenciador_drive';
const PYTHON_SCRIPT_FILE = 'GerenciadorDriveGoogle.py';
// ❌ WRONG - Do NOT use these
// const PYTHON_SCRIPT_DIR = '/home/arboreolab/estudos/1_funcionais/fregeRAG_v1';
// const PYTHON_SCRIPT_FILE = 'fregeRAG.py'; - Fixed (2026-01-02):
node/backend/routes/gerenciadorDriveGoogle.jslines 21-23
- Symptom: Sync stuck at "Mapeando Google Drive..." (20%), PM2 logs show
-
Path Duplication in Google Drive Structure Read:
- Symptom: "Erro ao carregar estrutura de arquivos" on
/armazenamentopage, PM2 logs showENOENTfile not found - Cause: After fixing the Python script location, the
readDriveStructurefunction still added redundant path segment - Wrong Path:
.../gerenciador_drive/gerenciador_drive/GoogleDrive/{email}/... - Correct Path:
.../gerenciador_drive/GoogleDrive/{email}/... - Solution: Remove duplicate path segment in
readDriveStructurefunction:// ✅ CORRECT - PYTHON_SCRIPT_DIR already ends in 'gerenciador_drive'
const structurePath = path.join(PYTHON_SCRIPT_DIR, 'GoogleDrive', email, 'drive_structure.json');
// ❌ WRONG - Adding 'gerenciador_drive' again
// const structurePath = path.join(PYTHON_SCRIPT_DIR, 'gerenciador_drive', 'GoogleDrive', ...); - Fixed (2026-01-02):
node/backend/routes/gerenciadorDriveGoogle.jslines 247-249
- Symptom: "Erro ao carregar estrutura de arquivos" on
🔄 Tainacan Integration Patterns
Taxonomy Field Mapping
When syncing taxonomy fields between the project and Tainacan:
-
Load taxonomy terms once and cache them:
const taxonomyTermsCache = ref<Record<number, Array<{ id: number; name: string }>>>({})
async function loadTaxonomyTerms(taxonomyId: number) {
if (taxonomyTermsCache.value[taxonomyId]) {
return taxonomyTermsCache.value[taxonomyId]
}
const response = await axiosInstance.get(
`${TAINACAN_API_BASE_URL}/taxonomy/${taxonomyId}/terms`,
{ params: { perpage: 1000, hideempty: 0 } }
)
const terms = response.data.map((t: any) => ({ id: t.id, name: t.name }))
terms.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR'))
taxonomyTermsCache.value[taxonomyId] = terms
return terms
} -
Map project fields to Tainacan taxonomies automatically:
const knownMappings: Record<string, string[]> = {
'metadata_ponto_acesso_assuntos': ['assunto', 'assuntos', 'ponto de acesso - assunto'],
'metadata_ponto_acesso_nomes': ['nomes', 'ponto de acesso - nomes'],
'metadata_procedencia': ['procedência', 'procedencia', 'provenance'],
// ... more mappings
} -
Provide suggestions in the form based on available terms:
const taxonomySuggestionsForEditor = computed(() => {
const suggestions: Record<string, Array<{ id: number; name: string }>> = {}
for (const [fieldKey, mapping] of Object.entries(fieldTaxonomyMapping.value)) {
if (mapping.terms?.length > 0) {
suggestions[fieldKey] = mapping.terms
}
}
return suggestions
})
Sync Workflow Best Practices
-
Always validate before sync:
const validation = validateForSync(editableMetadata.value)
if (!validation.valid) {
showToast('warning', 'Atenção', `Campos obrigatórios: ${validation.missing.join(', ')}`)
return
} -
Use the correct endpoint for each operation:
Operation Endpoint Method Create item /collection/{id}/itemsPOST Update item core fields /items/{id}PATCH Update item metadata /item/{id}/metadata/{meta_id}PATCH Get item /items/{id}GET Search items /collection/{id}/items?metaquery[...]GET -
Handle both name-based and ID-based taxonomy values:
async function updateSingleMetadata(itemId: number, meta: any, value: any) {
try {
// Try sending by name first
await axiosInstance.patch(
`${TAINACAN_API_BASE_URL}/item/${itemId}/metadata/${meta.id}`,
{ values: termNames }
)
} catch (error) {
// If failed, try with IDs
const termIds = await resolveTermNamesToIds(taxonomyId, termNames)
await axiosInstance.patch(
`${TAINACAN_API_BASE_URL}/item/${itemId}/metadata/${meta.id}`,
{ values: termIds }
)
}
}
📁 Storage Configuration Patterns
Pasta Raiz do Projeto (Root Folder Selection)
O sistema permite que administradores configurem uma pasta raiz no Google Drive, que define o escopo de acesso para membros convidados do projeto.
Localização: ConfigModal.vue → aba "Armazenamento" → seção "Configurações do Google Drive"
Fluxo de Seleção de Pasta Raiz
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUXO DE SELEÇÃO DE PASTA RAIZ │
│ │
│ 1. ADMIN ABRE CONFIGURAÇÕES │
│ ├── ConfigModal → aba "Armazenamento" │
│ └── Clica em "Alterar" na seção "Pasta Raiz" │
│ │
│ 2. MODAL DE SELEÇÃO ABRE │
│ ├── Carrega pastas via GET /api/google-drive/structure/:email │
│ ├── Filtra apenas itens onde isFolder === true │
│ └── Exibe lista de pastas da raiz do Drive │
│ │
│ 3. ADMIN SELECIONA PASTA │
│ ├── Clica na pasta desejada (highlight visual) │
│ └── Clica em "Confirmar" │
│ │
│ 4. SALVA NO BACKEND │
│ ├── PUT /api/projeto/:id │
│ ├── Campos: storage_root_id, storage_root_name │
│ └── Atualiza store local: nuvem_pasta_raiz_id, nuvem_pasta_raiz_nome │
│ │
│ 5. IMPACTO PARA CONVIDADOS │
│ ├── Gerenciador de Arquivos usa pasta raiz como escopo │
│ └── Convidados só veem arquivos dentro desta pasta │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Campos do Backend
| Campo Frontend | Campo Backend | Descrição |
|---|---|---|
nuvem_pasta_raiz_id | storage_root_id | ID da pasta no Google Drive |
nuvem_pasta_raiz_nome | storage_root_name | Nome da pasta para exibição |
Interface TypeScript
// Tipo para pastas do Drive
interface DriveFolder {
id: string
name: string
}
// Estados necessários
const showFolderModal = ref(false)
const driveRootFolders = ref<DriveFolder[]>([])
const isLoadingFolders = ref(false)
const selectedFolder = ref<DriveFolder | null>(null)
const folderError = ref<string | null>(null)
const folderSuccessMessage = ref<string | null>(null)
Funções Principais
// Abrir modal e carregar pastas
function selecionarPasta() {
showFolderModal.value = true
folderError.value = null
selectedFolder.value = null
carregarPastasRaiz()
}
// Buscar pastas do Google Drive
async function carregarPastasRaiz() {
isLoadingFolders.value = true
const adminEmail = projetoStore.projeto?.admin_email
const response = await axiosInstance.get(
`/api/google-drive/structure/${encodeURIComponent(adminEmail)}`
)
if (response.data?.success && response.data?.tree) {
// Filtrar apenas pastas da raiz
driveRootFolders.value = response.data.tree
.filter((item: { isFolder?: boolean }) => item.isFolder === true)
.map((item: { id: string; name: string }) => ({
id: item.id,
name: item.name
}))
}
}
// Salvar pasta selecionada
async function salvarPastaRaiz() {
await axiosInstance.put(`/api/projeto/${projetoId}`, {
storage_root_id: selectedFolder.value.id,
storage_root_name: selectedFolder.value.name
})
// Atualizar store local
projetoStore.projeto.nuvem_pasta_raiz_id = selectedFolder.value.id
projetoStore.projeto.nuvem_pasta_raiz_nome = selectedFolder.value.name
}
API Endpoints Envolvidos
| Método | Rota | Descrição |
|---|---|---|
GET | /api/google-drive/structure/:email | Retorna estrutura de pastas do Drive |
PUT | /api/projeto/:id | Atualiza campos do projeto incluindo pasta raiz |
CSS do Modal de Seleção
O modal usa z-index 2000 para aparecer sobre o ConfigModal:
.folder-modal-overlay {
position: fixed;
z-index: 2000;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.folder-item.selected {
background: rgba(45, 80, 22, 0.1);
border-color: var(--arboreo-forest);
}
🎨 UI/UX Patterns
Standard View Layout
All views should follow the Armazenamento.vue pattern:
<template>
<div class="page-name">
<!-- Header Component -->
<Header :app-name="'Clio.'" :app-name2="'SIGAD'" :app-subtitle="'Page Title'" />
<main class="page-content">
<div class="container-fluid py-4">
<!-- Page Header Section -->
<section class="page-header mb-4">...</section>
<!-- Search/Filter Section -->
<section class="search-section mb-4">...</section>
<!-- Results/Content Section -->
<section class="results-section">...</section>
<!-- Toast Notifications -->
<div v-if="errorMessage" class="toast-container">...</div>
</div>
</main>
</div>
</template>
Entity Type Color Scheme
Consistent colors for entity types across components:
.type-pessoa { background: linear-gradient(135deg, #007bff, #0056b3); }
.type-organizacao { background: linear-gradient(135deg, #6f42c1, #5a32a3); }
.type-lugar { background: linear-gradient(135deg, #28a745, #1e7e34); }
.type-evento { background: linear-gradient(135deg, #fd7e14, #dc6a0c); }
.type-obra { background: linear-gradient(135deg, #e83e8c, #c61a70); }
.type-data { background: linear-gradient(135deg, #17a2b8, #117a8b); }
.type-outro { background: linear-gradient(135deg, #6c757d, #545b62); }
Integrity Status Color Scheme
Consistent colors for data integrity status:
.status-success { /* 3/3 tables */
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.status-warning { /* 2/3 tables */
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.status-error { /* 1/3 or 0/3 tables */
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
Responsive Grid Breakpoints
Standard breakpoints for grid layouts:
/* 3 columns on large screens */
.grid { grid-template-columns: repeat(3, 1fr); }
/* 2 columns on medium screens */
@media (max-width: 992px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
/* 1 column on small screens */
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
Properties Panel Action Buttons
Standard pattern for action buttons in properties/detail panels:
<div class="action-buttons mt-4">
<button class="btn btn-outline-primary" @click="emit('open-entities')">
<i class="bi bi-diagram-2 me-1"></i>
Entidades e Relacionamentos
</button>
<button class="btn btn-outline-info" @click="emit('open-ocr')">
<i class="bi bi-file-text me-1"></i>
Ver OCR
</button>
</div>
.action-buttons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
👥 Sistema de Equipe e Convites
Arquitetura do Sistema de Equipe
O sistema permite que proprietários de projetos convidem outros usuários para colaborar, com diferentes níveis de permissão.
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUXO DE CONVITE DE EQUIPE │
│ │
│ 1. PROPRIETÁRIO ENVIA CONVITE │
│ ├── POST /api/projeto/:id/equipe │
│ ├── Verifica se email já está cadastrado em arb_usuarios │
│ ├── Se não: INSERT em arb_usuarios (status='pendente') │
│ └── INSERT em arb_projeto_usuarios │
│ ⚠️ Observação (implementação atual): este endpoint pode adicionar o
│ membro diretamente como `status='ativo'` (sem etapa de aceite),
│ então a listagem de convites pendentes pode continuar vazia.
│ │
│ 2. CONVITE ENVIADO (Email/Link) │
│ ├── Link: /projeto/aceitar-convite?token={token}&projeto={id} │
│ └── Token: base64(JSON.stringify({email, projetoId, role, exp})) │
│ │
│ 3. CONVIDADO ACESSA O LINK │
│ ├── Se não logado: redireciona para /login?redirect=... │
│ └── Se logado: verifica token e processa aceitação │
│ │
│ 4. ACEITAÇÃO DO CONVITE │
│ ├── POST /api/projeto/aceitar-convite │
│ ├── Valida token e email do usuário logado │
│ ├── UPDATE arb_projeto_usuarios SET status='ativo' │
│ ├── UPDATE arb_usuarios SET status='ativo', email_verificado=1 │
│ └── Redireciona para a página do projeto │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoints da API de Equipe
| Método | Rota | Descrição |
|---|---|---|
GET | /api/projeto/:id/equipe | Lista membros da equipe do projeto |
POST | /api/projeto/:id/equipe | Adiciona membro à equipe (endpoint atual; substitui o antigo /equipe/convidar) |
GET | /api/projeto/convites/pendentes | Lista convites pendentes do usuário autenticado |
POST | /api/projeto/convites/:conviteId/aceitar | Aceita convite pendente |
POST | /api/projeto/convites/:conviteId/recusar | Recusa convite pendente |
DELETE | /api/projeto/:id/equipe/:usuarioId | Remove membro da equipe |
PATCH | /api/projeto/:id/equipe/:membroId | Atualiza role/permissões do membro |
Nota importante (identificador do DELETE): o endpoint DELETE /api/projeto/:id/equipe/:usuarioId espera o usuarioId = arb_usuarios.id (FK), e não o id da tabela arb_projeto_usuarios.
Roles e Permissões
| Role | Leitura | Escrita | Exclusão | Admin |
|---|---|---|---|---|
admin | ✅ | ✅ | ✅ | ✅ |
gestor | ✅ | ✅ | ✅ | ❌ |
usuario | ✅ | ✅ | ❌ | ❌ |
visitante | ✅ | ❌ | ❌ | ❌ |
Geração de Token de Convite
// Gerar token de convite
function gerarTokenConvite(email, projetoId, role) {
const payload = {
email,
projetoId,
role,
exp: Date.now() + (7 * 24 * 60 * 60 * 1000) // 7 dias
};
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
// Validar token de convite
function validarTokenConvite(token) {
try {
const payload = JSON.parse(Buffer.from(token, 'base64').toString('utf8'));
if (payload.exp < Date.now()) {
return { valid: false, error: 'Token expirado' };
}
return { valid: true, payload };
} catch (e) {
return { valid: false, error: 'Token inválido' };
}
}
Query para Listar Projetos do Usuário (Owner + Membro)
-- Retorna projetos onde o usuário é proprietário OU membro ativo
SELECT DISTINCT
p.id as projeto_id,
p.nome as projeto_nome,
p.slug as projeto_slug,
p.status as projeto_status,
CASE WHEN p.usuario_id = ? THEN 'owner' ELSE pu.role END as minha_role,
CASE WHEN p.usuario_id = ? THEN 1 ELSE 0 END as sou_proprietario
FROM arb_projetos p
LEFT JOIN arb_projeto_usuarios pu
ON p.id = pu.projeto_id
AND pu.usuario_id = ?
AND pu.status = 'ativo'
WHERE p.usuario_id = ?
OR (pu.usuario_id = ? AND pu.status = 'ativo')
ORDER BY sou_proprietario DESC, p.created_at DESC
Frontend - Componente de Gerenciamento de Equipe
Localização: src/views/Projeto/GerenciadorProjeto.vue (seção de equipe no dashboard)
// Interface para membro da equipe
interface MembroEquipe {
id: number
usuario_id: number
email: string
nome: string | null
avatar_url: string | null
role: 'admin' | 'gestor' | 'usuario' | 'visitante'
status: 'pendente' | 'ativo' | 'suspenso' | 'removido'
isOwner?: boolean
}
// Carregar equipe
async function loadEquipe() {
if (!projetoId.value) return
isLoadingEquipe.value = true
try {
const response = await axiosInstance.get(`/api/projeto/${projetoId.value}/equipe`)
if (response.data?.equipe) {
equipeMembros.value = response.data.equipe
}
} catch (e) {
console.error('Erro ao carregar equipe:', e)
} finally {
isLoadingEquipe.value = false
}
}
// Convidar membro (via modal ConfigModal)
async function convidarMembro(projetoId: number, email: string, role: string) {
const response = await axiosInstance.post(
`/api/projeto/${projetoId}/equipe`,
{ email, role }
);
return response.data;
}
Fluxo de Aceitação de Convite (Frontend)
Arquivo: src/views/Projeto/AceitarConvite.vue
// Ao montar o componente
onMounted(async () => {
const token = route.query.token as string;
const projetoId = route.query.projeto as string;
if (!token || !projetoId) {
error.value = 'Convite inválido';
return;
}
// Verificar se está logado
if (!authStore.isAuthenticated) {
// Salvar URL atual e redirecionar para login
const redirectUrl = `/projeto/aceitar-convite?token=${token}&projeto=${projetoId}`;
router.push({ name: 'login', query: { redirect: redirectUrl }});
return;
}
// Aceitar convite
try {
const response = await axiosInstance.post('/api/projeto/aceitar-convite', {
token,
projetoId: parseInt(projetoId)
});
if (response.data.success) {
showToast('success', 'Convite aceito com sucesso!');
router.push({ name: 'projeto' });
}
} catch (e: any) {
error.value = e.response?.data?.error || 'Erro ao aceitar convite';
}
});
🏠 Página Inicial (inicio.vue)
A página inicial (src/views/inicio/inicio.vue) exibe um painel de controle com 3 colunas principais.
Estrutura do Painel de Controle
| Coluna | Título | Conteúdo |
|---|---|---|
| 1 | Atividade Recente / Stats | Estatísticas e atividades recentes |
| 2 | Minha Conta | Dados do usuário logado e assinatura |
| 3 | Meu Projeto | Projeto vinculado (proprietário ou convidado) |
Coluna 2: Minha Conta
Exibe dados carregados de duas fontes:
currentUserData: Computed do auth store (nome, email, avatar, role do sistema)userData: Ref carregada da API/api/projeto/meu-perfil(organização, último login, plano, idioma)
Carregamento de dados:
const loadUserData = async () => {
const response = await axiosInstance.get('/api/projeto/meu-perfil')
if (response.data?.success && response.data?.usuario) {
const u = response.data.usuario
// O plano está dentro do primeiro projeto (se existir)
const primeiroProjeto = response.data.projetos?.[0]
userData.value = {
organizacao: u.organizacao || '',
ultimoLogin: u.ultimo_login || null,
status: u.status || 'ativo',
plano: primeiroProjeto?.plano || 'gratuito', // ⚠️ Importante: plano vem do projeto!
idioma: u.idioma || 'pt-BR'
}
}
}
Formatação do Plano:
const formatPlano = (plano: string): string => {
const labels: Record<string, string> = {
'gratuito': '🆓 Gratuito',
'basico': '⭐ Básico',
'profissional': '🚀 Profissional',
'enterprise': '🏢 Enterprise'
}
return labels[plano] || plano
}
Coluna 3: Meu Projeto (Multi-tenant com Convidados)
A terceira coluna exibe projetos onde o usuário é proprietário OU membro convidado.
Interface ProjetoListItem (store/projeto.ts):
export interface ProjetoListItem {
id: number
nome: string
slug: string
descricao: string | null
database_name: string
instalacao_completa: boolean
instalacao_etapa: number
storage_type: string | null
storage_root_name: string | null
tainacan_enabled: boolean
created_at: string
updated_at: string
// Campos de assinatura
plano?: string | null
// Campos de permissão (para membros/convidados)
minha_role?: 'owner' | 'admin' | 'gestor' | 'usuario' | 'visitante'
sou_proprietario?: boolean | number
}
Endpoint /api/projeto/meus-projetos (Backend):
SELECT DISTINCT
p.id, p.nome, p.slug, p.descricao, p.database_name,
p.instalacao_completa, p.instalacao_etapa,
p.storage_type, p.storage_root_name, p.tainacan_enabled,
p.created_at, p.updated_at,
a.plano,
CASE WHEN p.usuario_id = ? THEN 'owner' ELSE pu.role END as minha_role,
CASE WHEN p.usuario_id = ? THEN 1 ELSE 0 END as sou_proprietario
FROM arb_projetos p
LEFT JOIN arb_projeto_usuarios pu ON p.id = pu.projeto_id AND pu.usuario_id = ? AND pu.status = 'ativo'
LEFT JOIN arb_assinaturas a ON p.id = a.projeto_id
WHERE p.usuario_id = ? OR (pu.usuario_id = ? AND pu.status = 'ativo')
ORDER BY sou_proprietario DESC, p.updated_at DESC
Funções de Verificação de Permissão (Frontend):
// Verifica se o usuário é proprietário do projeto
const isProjetoProprietario = (projeto: ProjetoListItem | null): boolean => {
if (!projeto) return false
return projeto.sou_proprietario === true ||
projeto.sou_proprietario === 1 ||
projeto.minha_role === 'owner'
}
// Formata o role do projeto para exibição
const formatProjetoRole = (role: string | undefined): string => {
if (!role) return 'Membro'
const labels: Record<string, string> = {
'owner': 'Proprietário',
'admin': 'Admin',
'gestor': 'Gestor',
'usuario': 'Usuário',
'visitante': 'Visitante'
}
return labels[role] || role
}
Comportamento Visual:
| Tipo de Usuário | Badge Exibido | Botão do Footer |
|---|---|---|
| Proprietário | ⭐ Proprietário (badge warning) | "Configurar Projeto" → /projeto |
| Convidado | Alert info "Você é convidado como [role]" | "Acessar Documentos" → /gerenciador |
| Sem Projeto | (nenhum) | "Criar Projeto" → /projeto/wizard |
Template do Dropdown com Badge de Role:
<div
v-for="projeto in meusProjetos"
:key="projeto.id"
class="project-dropdown-item"
>
<i class="bi bi-folder me-2"></i>
<span>{{ projeto.nome }}</span>
<!-- Badge de role no item do dropdown -->
<span
v-if="!isProjetoProprietario(projeto)"
class="badge bg-info ms-2"
>
{{ formatProjetoRole(projeto.minha_role) }}
</span>
</div>
Alert de Convidado:
<div
v-if="!isProjetoProprietario(projetoSelecionado)"
class="alert alert-info py-2 px-3 mb-0"
>
<i class="bi bi-person-badge me-2"></i>
<strong>Você é convidado</strong> deste projeto como
<span class="badge bg-secondary ms-1">
{{ formatProjetoRole(projetoSelecionado.minha_role) }}
</span>
</div>
📊 Padrões de Resposta da API
Estrutura de Resposta para /api/projeto/meu-perfil
{
"success": true,
"usuario": {
"id": 28,
"email": "recruta@gmail.com",
"nome": "Douglas Romao",
"organizacao": "FAPESP",
"status": "ativo",
"role": "usuario",
"ultimo_login": "2025-12-23T22:55:13.000Z",
"idioma": "pt-BR"
},
"projetos": [
{
"projeto_id": 4,
"projeto_nome": "Geopoliticas Institucionais",
"plano": "enterprise",
"minha_role": "usuario",
"sou_proprietario": 0
}
],
"uso_total": {
"documentos": 0,
"storage_gb": 0,
"ocr_mensal": 0
}
}
Estrutura de Resposta para /api/projeto/meus-projetos
{
"success": true,
"projetos": [
{
"id": 4,
"nome": "Geopoliticas Institucionais",
"slug": "geopoliticas-institucionais",
"database_name": "clio_geopoliticas",
"instalacao_completa": true,
"plano": "enterprise",
"minha_role": "usuario",
"sou_proprietario": 0
}
],
"total": 1
}
📊 Dashboard de Estatísticas e Métricas
O sistema Clio possui um dashboard completo para visualização de estatísticas e métricas de processamento, gestão arquivística e armazenamento.
Arquitetura do Sistema de Estatísticas
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE ESTATÍSTICAS CLIO │
│ │
│ FRONTEND (Vue.js) │
│ ├── src/views/Dashboard/DashboardEstatisticas.vue ← View principal │
│ ├── src/composables/useStats.ts ← Composable reativo │
│ └── src/router/index.ts ← Rota /estatisticas │
│ │
│ BACKEND (Node.js) │
│ ├── node/backend/routes/stats.js ← API REST (15+ endpoints)│
│ └── node/backend/scripts/migrate_stats_tables.js ← Migração multi-tenant │
│ │
│ BANCO DE DADOS │
│ ├── metricas_historico_diario ← Snapshots diários │
│ └── clio_stats_{database_name} ← Tabela por projeto │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoints da API de Estatísticas
| Categoria | Endpoint | Descrição |
|---|---|---|
| IDP | GET /api/stats/idp/ocr-success | Taxa de sucesso do OCR por coleção |
| IDP | GET /api/stats/idp/ner-correction | Taxa de correção humana em entidades (NER) |
| IDP | GET /api/stats/idp/entities-volume | Volume de entidades extraídas por tipo |
| IDP | GET /api/stats/idp/top-entities | Top entidades mais citadas |
| SIGAD | GET /api/stats/sigad/volumetria | Volumetria por fundo/coleção |
| SIGAD | GET /api/stats/sigad/metadata-quality | Completeza de metadados (e-ARQ Brasil) |
| SIGAD | GET /api/stats/sigad/document-types | Distribuição por gênero documental |
| SIGAD | GET /api/stats/sigad/tainacan-sync | Status de sincronização Tainacan |
| Storage | GET /api/stats/storage/by-format | Armazenamento por formato MIME |
| Storage | GET /api/stats/storage/obsolete-formats | Formatos em risco de obsolescência |
| Storage | GET /api/stats/storage/duplicates | Detecção de arquivos duplicados |
| Dashboard | GET /api/stats/dashboard | Visão consolidada de todas as métricas |
| Dashboard | POST /api/stats/snapshot | Gera snapshot diário (manual) |
| Dashboard | GET /api/stats/history | Histórico de métricas |
| Dashboard | GET /api/stats/evolution | Evolução temporal para gráficos |
Composable useStats
O composable src/composables/useStats.ts fornece acesso reativo a todas as métricas:
import { useStats } from '@/composables/useStats'
const {
// Estado
isLoading,
error,
lastUpdate,
// Dados
dashboardData,
evolutionData,
entitiesVolume,
metadataQuality,
storageByFormat,
tainacanSync,
// Computed
totalDocumentos,
taxaSucessoOcr,
totalEntidadesUnicas,
espacoTotalGb,
// Funções
fetchDashboard,
fetchEvolution,
loadAll,
generateSnapshot,
// Helpers
formatNumber,
formatBytes,
formatDate
} = useStats()
// Carregar todos os dados
await loadAll()
// Ou carregar específicos
await fetchDashboard()
await fetchEvolution(30) // últimos 30 dias
Interfaces TypeScript
/** Estatísticas de documentos */
interface DocumentStats {
total_documentos: number
ocr_concluido: number
entidades_concluido: number
publicados: number
total_colecoes: number
}
/** Status do pipeline */
interface PipelineStatus {
etapa: 'OCR' | 'Entidades' | 'Tainacan'
pendente: number
processando: number
concluido: number
erro: number
}
/** Dados do dashboard consolidado */
interface DashboardData {
documentos: DocumentStats
entidades: EntityStats
armazenamento: StorageStats
atividade_recente: RecentActivity[]
pipeline: PipelineStatus[]
gerado_em: string
}
/** Dados de evolução temporal */
interface EvolutionData {
data_referencia: string
total_documentos: number
total_ocr_sucesso: number
total_erros: number
}
Estrutura de Resposta para /api/stats/dashboard
{
"success": true,
"data": {
"documentos": {
"total_documentos": 15234,
"ocr_concluido": 12456,
"entidades_concluido": 10234,
"publicados": 8567,
"total_colecoes": 12
},
"entidades": {
"total_registros": 145478,
"entidades_unicas": 71224,
"tipos_entidade": 25
},
"armazenamento": {
"total_arquivos": 45678,
"total_gb": 125.5,
"formatos_distintos": 8
},
"atividade_recente": [
{ "data": "2025-12-24", "novos_registros": 45 },
{ "data": "2025-12-23", "novos_registros": 32 }
],
"pipeline": [
{ "etapa": "OCR", "pendente": 500, "processando": 12, "concluido": 12456, "erro": 23 },
{ "etapa": "Entidades", "pendente": 2000, "processando": 5, "concluido": 10234, "erro": 15 },
{ "etapa": "Tainacan", "pendente": 3000, "processando": 0, "concluido": 8567, "erro": 45 }
],
"gerado_em": "2025-12-24T11:26:30.056Z"
}
}
Tabela de Snapshots Diários (Multi-Tenant)
Para cada projeto, existe uma tabela de estatísticas seguindo a convenção:
clio_stats_{database_name}
Campos principais:
- Métricas IDP:
ocr_concluido,ocr_erro,ner_concluido,entidades_pessoa,entidades_organizacao - Métricas SIGAD:
docs_com_codigo_referencia,docs_com_data_producao,indice_qualidade_metadados - Métricas Storage:
tamanho_total_gb,arquivos_pdf,arquivos_jpg,arquivos_duplicados
Script de migração:
cd /home/arboreolab/Clio/node/backend/scripts
node migrate_stats_tables.js # Modo dry-run
node migrate_stats_tables.js --execute # Executar migração
Configurar CRON para Snapshots Diários
# Adicionar ao crontab (rodar às 03:00 AM)
0 3 * * * curl -X POST http://localhost:3000/api/stats/snapshot
CSS do Dashboard
O dashboard usa o padrão de cores Arboreo:
/* Cores dos KPIs */
.kpi-documents { border-left-color: #2d5016; } /* Forest green */
.kpi-success { border-left-color: #28a745; } /* Success green */
.kpi-entities { border-left-color: #6f42c1; } /* Purple */
.kpi-storage { border-left-color: #17a2b8; } /* Cyan */
/* Cores das etapas do pipeline */
.pipeline-ocr .pipeline-icon { background: linear-gradient(135deg, #2d5016, #4a7c23); }
.pipeline-entidades .pipeline-icon { background: linear-gradient(135deg, #6f42c1, #9b59b6); }
.pipeline-tainacan .pipeline-icon { background: linear-gradient(135deg, #17a2b8, #20c997); }
Gráficos com Chart.js
O dashboard usa Chart.js para renderização de gráficos:
import Chart from 'chart.js/auto'
// Gráfico de evolução (linha)
const evolutionChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['01/12', '02/12', '03/12'],
datasets: [
{ label: 'Total Documentos', data: [100, 150, 200], borderColor: '#2d5016' },
{ label: 'OCR Sucesso', data: [90, 140, 190], borderColor: '#28a745' },
{ label: 'Erros', data: [10, 10, 10], borderColor: '#dc3545' }
]
}
})
// Gráfico de armazenamento (pizza)
const storageChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['PDF', 'JPEG', 'PNG', 'TIFF'],
datasets: [{
data: [500, 300, 100, 50],
backgroundColor: ['#2d5016', '#4a7c23', '#6b8e23', '#8fbc8f']
}]
}
})
Acesso ao Dashboard
- Rota:
/estatisticas - Quick Actions: Card "Estatísticas e Métricas" no Header
- Autenticação: Requer login (
meta: { requiresAuth: true })
🔔 Google Drive Push Notifications (Webhooks)
O sistema Clio usa Push Notifications do Google Drive para sincronização automática de arquivos, evitando polling e mantendo a tabela gerenciamento_googledrive atualizada em tempo real.
Arquitetura do Sistema de Webhooks
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE WEBHOOKS DO GOOGLE DRIVE │
│ │
│ FLUXO DE NOTIFICAÇÃO │
│ ┌──────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Google Drive │───►│ POST /callback │───►│ Atualiza Tabela │ │
│ │ (detecta mudança)│ │ (driveWebhook.js) │ │ gerenciamento_ │ │
│ │ │ │ │ │ googledrive │ │
│ └──────────────────┘ └────────────────────┘ └────────────────────┘ │
│ │
│ COMPONENTES │
│ ├── node/backend/routes/driveWebhook.js ← API REST completa │
│ ├── node/scripts/drive-webhook-cron.sh ← Renovação automática │
│ └── ClioVector.drive_notification_channels ← Canais ativos │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoints da API de Webhooks
| Método | Rota | Descrição |
|---|---|---|
POST | /api/drive-webhook/callback | Recebe notificações do Google (sync/changes) |
POST | /api/drive-webhook/register | Registra novo canal de notificação |
POST | /api/drive-webhook/renew | Renova canais próximos de expirar |
DELETE | /api/drive-webhook/stop | Para de monitorar (cancela canal) |
GET | /api/drive-webhook/status/:email | Status dos canais ativos do usuário |
Tabela drive_notification_channels
CREATE TABLE drive_notification_channels (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id VARCHAR(255) NOT NULL UNIQUE, -- UUID gerado pelo sistema
resource_id VARCHAR(255) NOT NULL, -- ID do recurso no Google
email VARCHAR(255) NOT NULL, -- Email do usuário OAuth
channel_type ENUM('changes','files') DEFAULT 'changes',
expiration DATETIME NOT NULL, -- Quando expira (máx 7 dias)
token VARCHAR(255), -- Token de verificação
start_page_token VARCHAR(255), -- Cursor para changes.list()
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
status ENUM('active','expired','stopped') DEFAULT 'active',
INDEX idx_email (email),
INDEX idx_status (status),
INDEX idx_expiration (expiration)
);
Fluxo de Processamento de Notificações
- Google Drive detecta mudança (arquivo criado, movido, excluído, renomeado)
- Envia POST para callback URL com headers:
X-Goog-Channel-ID: ID do canalX-Goog-Resource-State:sync(inicial) ouchangeX-Goog-Resource-ID: ID do recurso monitorado
- driveWebhook.js processa:
// Busca mudanças desde o último token
const changesResponse = await drive.changes.list({
pageToken: startPageToken,
fields: 'changes(file(id,name,mimeType,parents,trashed,modifiedTime,webViewLink)),newStartPageToken'
}); - Para cada arquivo alterado:
trashed: true→ Marcaremoved = 1na tabela- Arquivo modificado → Atualiza metadados
- Arquivo novo → Insere na tabela
Registrar Novo Canal
# Via curl
curl -X POST http://localhost:3000/api/drive-webhook/register \
-H "Content-Type: application/json" \
-d '{
"email": "adm.geopoliticas@gmail.com",
"channelType": "changes"
}'
# Resposta
{
"success": true,
"message": "Canal de notificação registrado com sucesso",
"channel": {
"channelId": "410abb05-2b1b-4dc2-8d69-ef9d0273e142",
"resourceId": "ABC123...",
"expiration": "2026-01-02T19:49:53.225Z",
"expiresIn": "168 horas"
}
}
Verificar Status
curl "http://localhost:3000/api/drive-webhook/status/adm.geopoliticas%40gmail.com"
# Resposta
{
"success": true,
"email": "adm.geopoliticas@gmail.com",
"hasActiveChannels": true,
"activeCount": 1,
"channels": [{
"channelId": "410abb05-...",
"channelType": "changes",
"status": "active",
"expiresIn": "120 horas",
"createdAt": "2025-12-28T19:49:53.000Z"
}]
}
Script de Renovação Automática (CRON)
O script drive-webhook-cron.sh executa diariamente às 3:00 AM:
# Localização
/home/arboreolab/Clio/node/scripts/drive-webhook-cron.sh
# Entrada no crontab
0 3 * * * /home/arboreolab/Clio/node/scripts/drive-webhook-cron.sh >> /var/log/drive-webhook-cron.log 2>&1
Funcionalidades do script:
- Verifica se o backend está rodando
- Para cada email configurado:
- Verifica status dos canais ativos
- Se canal expira em menos de 48 horas → Renova
- Se não há canal ativo → Registra novo
- Grava logs em
/var/log/drive-webhook-cron.log
Configurar novos emails no script:
# Editar o array EMAILS no script
EMAILS=(
"adm.geopoliticas@gmail.com"
"outro.usuario@gmail.com"
)
Credenciais OAuth
Os tokens OAuth são armazenados por usuário em:
/home/arboreolab/estudos/1_funcionais/fregeRAG_v1/gerenciador_drive/GoogleDrive/{email}/token.json
Formato do token.json:
{
"access_token": "ya29.a0...",
"refresh_token": "1//0e...",
"scope": "https://www.googleapis.com/auth/drive",
"token_type": "Bearer",
"expiry_date": 1735412993225
}
Requisitos para Webhooks Funcionarem
-
URL HTTPS válida: O callback deve ser acessível publicamente via HTTPS
- URL configurada:
https://srv1.arboreolab.com.br/api/drive-webhook/callback
- URL configurada:
-
Domínio verificado no Google Cloud Console:
- Acessar: Console → APIs & Services → Domain verification
- Adicionar:
srv1.arboreolab.com.br
-
Drive API habilitada com escopos corretos:
https://www.googleapis.com/auth/drivehttps://www.googleapis.com/auth/drive.metadata.readonly
Troubleshooting
| Problema | Causa | Solução |
|---|---|---|
| Callback não recebe notificações | Domínio não verificado | Verificar domínio no GCP Console |
| Token expirado | refresh_token inválido | Re-autenticar usuário via OAuth |
| Canal expira em 7 dias | Limitação do Google | CRON renova automaticamente |
| Erro 401 no callback | Token OAuth expirado | driveWebhook.js faz refresh automático |
| Arquivo não atualiza na tabela | changes.list() falha | Verificar startPageToken salvo |
Monitoramento
# Ver logs do cron
tail -f /var/log/drive-webhook-cron.log
# Ver logs do PM2 (callback)
pm2 logs node-backend-Arboreolab --lines 100 | grep -i webhook
# Verificar status de todos os canais
curl "http://localhost:3000/api/drive-webhook/status/adm.geopoliticas%40gmail.com"
# Forçar renovação manual
curl -X POST http://localhost:3000/api/drive-webhook/renew \
-H "Content-Type: application/json" \
-d '{"email":"adm.geopoliticas@gmail.com"}'
# Parar monitoramento
curl -X DELETE http://localhost:3000/api/drive-webhook/stop \
-H "Content-Type: application/json" \
-d '{"email":"adm.geopoliticas@gmail.com"}'
Sincronização Manual vs Automática
| Método | Quando Usar | Endpoint/Script |
|---|---|---|
| Webhook (automático) | Atualizações em tempo real | POST /callback (recebe do Google) |
| Map Folder (manual) | Sincronização inicial completa | Python MapeadorDriveGoogle.py |
| Sync Status | Verificar inconsistências | GET /api/gerenciador-dados/sync-status |
| Remove Obsolete | Limpar arquivos movidos | DELETE /api/drive-mapper/remove-obsolete |
📤 Upload Controlado para Usuários (role=usuario)
O sistema permite que usuários com role=usuario façam upload de arquivos de forma controlada, com restrições de tipo de arquivo e organização automática em pastas.
Arquitetura do Sistema de Upload Controlado
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE UPLOAD CONTROLADO (role=usuario) │
│ │
│ RESTRIÇÕES │
│ ├── Tipos permitidos: Imagens (jpg, png, gif, webp, tiff) e PDFs │
│ ├── Upload apenas para pastas específicas do usuário │
│ └── Estrutura de pastas preservada do local para o Drive │
│ │
│ ESTRUTURA DE PASTAS NO GOOGLE DRIVE │
│ ├── {Coleção selecionada}/ │
│ │ └── {email do usuário}/ │
│ │ └── 1 - Documentos originais/ │
│ │ └── {subpastas do usuário}/ ← Estrutura preservada │
│ │ └── arquivo.jpg │
│ │ │
│ FLUXO │
│ 1. Usuário seleciona coleção de destino │
│ 2. Arrasta pasta/arquivos OU seleciona via dialog │
│ 3. Frontend envia para /api/google-drive/prepare-user-upload │
│ 4. Backend cria hierarquia: Coleção > email > "1 - Documentos originais" │
│ 5. Frontend cria subpastas via /api/google-drive/ensure-folder │
│ 6. Arquivos são enviados via /api/google-drive/upload │
│ 7. Webhook do Google Drive atualiza tabela automaticamente │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoints da API de Upload Controlado
| Método | Rota | Descrição |
|---|---|---|
GET | /api/google-drive/collections | Lista coleções disponíveis para upload |
POST | /api/google-drive/prepare-user-upload | Prepara estrutura de pastas (cria se necessário) |
POST | /api/google-drive/ensure-folder | Garante existência de subpasta (verifica antes de criar) |
POST | /api/google-drive/upload | Faz upload do arquivo para pasta especificada |
Endpoint GET /collections
Retorna lista de coleções (pastas de primeiro nível) disponíveis para o usuário fazer upload.
Request:
curl "http://localhost:3000/api/google-drive/collections?email=adm.geopoliticas@gmail.com"
Response:
{
"success": true,
"collections": [
{ "id": "1ABC...", "name": "Acervo Geopolíticas" },
{ "id": "1DEF...", "name": "Coleção Arte Infantil" }
]
}
Endpoint POST /prepare-user-upload
Prepara a estrutura de pastas para o upload do usuário. Cria a hierarquia se não existir.
Request:
curl -X POST http://localhost:3000/api/google-drive/prepare-user-upload \
-H "Content-Type: application/json" \
-d '{
"email": "adm.geopoliticas@gmail.com",
"collectionId": "1ABC...",
"userEmail": "usuario@gmail.com",
"targetSubfolder": "1"
}'
Parâmetros:
email: Email do admin (OAuth tokens)collectionId: ID da coleção de destinouserEmail: Email do usuário que está fazendo uploadtargetSubfolder:"1"para "1 - Documentos originais" ou"3"para "3 - Documentos derivados"
Response:
{
"success": true,
"targetFolderId": "1XYZ...",
"path": "Acervo Geopolíticas/usuario@gmail.com/1 - Documentos originais"
}
Endpoint POST /ensure-folder
Verifica se uma pasta existe e cria apenas se necessário. Evita duplicatas.
Request:
curl -X POST http://localhost:3000/api/google-drive/ensure-folder \
-H "Content-Type: application/json" \
-d '{
"email": "adm.geopoliticas@gmail.com",
"parentFolderId": "1XYZ...",
"folderName": "@JAPON VII BIENAL1964"
}'
Response (pasta criada):
{
"success": true,
"folderId": "1hth6qoOVNNl8PuM80c2RGWm9nnXdWYLy",
"created": true
}
Response (pasta já existia):
{
"success": true,
"folderId": "1hth6qoOVNNl8PuM80c2RGWm9nnXdWYLy",
"created": false
}
Frontend - Componente de Upload com Triagem
Localização: src/components/gerenciadordearquivos/gerenciadordearquivos.vue
Estados do Modal de Triagem
const uploadTriageModal = ref({
visible: false,
files: [] as Array<File & { relativePath?: string; parentFolder?: string }>,
selectedCollection: null as { id: string; name: string } | null,
isUploading: false,
progress: 0,
currentFile: ''
})
const availableCollections = ref<Array<{ id: string; name: string }>>([])
Preservação de Estrutura de Pastas
O sistema preserva a estrutura de pastas do usuário usando webkitRelativePath:
Para arrastar pastas (drag & drop):
function handleTriageFileDrop(event: DragEvent) {
event.preventDefault()
const items = event.dataTransfer?.items
if (!items) return
const filePromises: Promise<File & { relativePath: string; parentFolder: string }>[] = []
for (const item of items) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.()
if (entry) {
filePromises.push(...traverseFileTree(entry, ''))
}
}
}
// traverseFileTree retorna arquivos com relativePath completo
// Ex: "@JAPON VII BIENAL1964/2025-03-06 13.07.09.jpg"
}
Para seleção via dialog (input file):
function handleTriageFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files) return
// webkitRelativePath contém o caminho completo quando usando webkitdirectory
const filesWithPath = Array.from(input.files).map(file => {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
const pathParts = relativePath.split('/')
const parentFolder = pathParts.length > 1 ? pathParts[0] : ''
return Object.assign(file, { relativePath, parentFolder })
})
uploadTriageModal.value.files = filesWithPath.filter(f => isAllowedFileType(f))
}
Execução do Upload com Hierarquia de Pastas
async function executeTriageUpload() {
const { files, selectedCollection } = uploadTriageModal.value
if (!selectedCollection || files.length === 0) return
uploadTriageModal.value.isUploading = true
// 1. Preparar pasta base (Coleção > email > 1 - Documentos originais)
const prepareResponse = await axiosInstance.post('/api/google-drive/prepare-user-upload', {
email: adminEmail,
collectionId: selectedCollection.id,
userEmail: currentUserEmail,
targetSubfolder: '1'
})
let baseFolderId = prepareResponse.data.targetFolderId
// 2. Cache de pastas para evitar chamadas duplicadas
const folderCache: Record<string, string> = {}
// 3. Upload de cada arquivo
for (const file of files) {
let targetFolderId = baseFolderId
// Se arquivo tem subpastas, criar hierarquia
if (file.relativePath && file.relativePath.includes('/')) {
const pathParts = file.relativePath.split('/')
pathParts.pop() // Remove o nome do arquivo
// Criar cada nível de pasta
for (const folderName of pathParts) {
const cacheKey = `${targetFolderId}:${folderName}`
if (folderCache[cacheKey]) {
targetFolderId = folderCache[cacheKey]
} else {
const folderResponse = await axiosInstance.post('/api/google-drive/ensure-folder', {
email: adminEmail,
parentFolderId: targetFolderId,
folderName: folderName
})
targetFolderId = folderResponse.data.folderId
folderCache[cacheKey] = targetFolderId
}
}
}
// Upload do arquivo
const formData = new FormData()
formData.append('file', file)
formData.append('folderId', targetFolderId)
formData.append('email', adminEmail)
await axiosInstance.post('/api/google-drive/upload', formData)
}
uploadTriageModal.value.isUploading = false
}
Tipos de Arquivo Permitidos
function isAllowedFileType(file: File): boolean {
const allowedTypes = [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'image/webp', 'image/tiff',
'application/pdf'
]
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.tif', '.pdf']
const hasValidMime = allowedTypes.includes(file.type)
const hasValidExtension = allowedExtensions.some(ext =>
file.name.toLowerCase().endsWith(ext)
)
return hasValidMime || hasValidExtension
}
Backend - Python Script (ensure-folder)
O script GerenciadorDriveGoogle.py foi modificado para verificar se a pasta existe antes de criar:
def ensure_folder(self, parent_folder_id: str, folder_name: str) -> dict:
"""
Garante que uma pasta existe. Verifica primeiro, cria apenas se necessário.
"""
try:
# 1. Buscar pasta existente
query = (
f"name = '{folder_name}' and "
f"'{parent_folder_id}' in parents and "
f"mimeType = 'application/vnd.google-apps.folder' and "
f"trashed = false"
)
results = self.service.files().list(
q=query,
spaces='drive',
fields='files(id, name)',
pageSize=1
).execute()
existing_folders = results.get('files', [])
# 2. Se existe, retornar ID
if existing_folders:
return {
'success': True,
'folderId': existing_folders[0]['id'],
'created': False
}
# 3. Se não existe, criar
file_metadata = {
'name': folder_name,
'mimeType': 'application/vnd.google-apps.folder',
'parents': [parent_folder_id]
}
folder = self.service.files().create(
body=file_metadata,
fields='id'
).execute()
return {
'success': True,
'folderId': folder.get('id'),
'created': True
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
Troubleshooting
| Problema | Causa | Solução |
|---|---|---|
| Estrutura de pastas não preservada (dialog) | webkitRelativePath não usado | Verificar handleTriageFileSelect usa webkitRelativePath |
| Estrutura de pastas não preservada (drag) | relativePath não propagado | Verificar traverseFileTree retorna relativePath |
| Pasta duplicada no Drive | Script não verifica existência | Usar endpoint /ensure-folder em vez de criar diretamente |
| Erro "Duplicate entry" no webhook | INSERT sem verificação | Webhook deve usar INSERT ... ON DUPLICATE KEY UPDATE |
| Upload falha para não-admin | Permissões insuficientes | Verificar userPermissions.canUpload |
Logs de Debug
O frontend emite logs de debug durante o upload:
console.log('[Upload Debug]', {
fileName: file.name,
relativePath: file.relativePath,
parentFolder: file.parentFolder,
targetFolder: targetFolderId
})
Para ver no console do navegador:
- Abrir DevTools (F12)
- Aba Console
- Filtrar por
[Upload Debug]
Verificação de Upload Bem-Sucedido
Após upload, verificar nos logs do PM2:
pm2 logs node-backend-Arboreolab --lines 50 | grep -E "(ensure-folder|upload|webhook)"
Saída esperada:
[ensure-folder] Pasta já existe: @JAPON VII BIENAL1964 → 1hth6qoOVNNl8PuM80c2RGWm9nnXdWYLy
[upload] Arquivo enviado: 2025-03-06 13.07.09.jpg → pasta 1hth6qoOVNNl8PuM80c2RGWm9nnXdWYLy
[webhook] Processando 11 mudanças...
[webhook] Arquivo adicionado: 2025-03-06 13.07.09.jpg
📄 Sistema de Tratamento de PDF (Conversão de Imagens)
O sistema Clio possui um módulo completo para conversão de imagens digitalizadas em PDFs arquivísticos, seguindo o padrão PDF/A-1b (ISO 19005-1) conforme e-ARQ Brasil.
Arquitetura do Sistema de Tratamento de PDF
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE TRATAMENTO DE PDF │
│ │
│ FLUXO DE CONVERSÃO │
│ ┌──────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Imagens em │───►│ Backend Node.js │───►│ PDF/A-1b com │ │
│ │ "1 - Documentos │ │ + Python/Pillow │ │ metadados XMP │ │
│ │ originais" │ │ + pikepdf │ │ │ │
│ └──────────────────┘ └────────────────────┘ └────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ "3 - Documentos │ │
│ │ PDF - final" │ │
│ └────────────────────┘ │
│ │
│ COMPONENTES │
│ ├── node/backend/routes/pdfTreatmentRoute.js ← API REST │
│ ├── gerenciadortratamentopdf.vue ← Interface Vue.js │
│ └── Python: Pillow + pikepdf ← Conversão + XMP │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Endpoints da API de Tratamento de PDF
| Método | Rota | Descrição |
|---|---|---|
POST | /api/pdf-treatment/convert | Converte uma imagem individual para PDF |
POST | /api/pdf-treatment/convert-concatenated | Concatena múltiplas imagens em um único PDF |
POST | /api/pdf-treatment/convert-batch | Conversão em lote (múltiplos PDFs separados) |
GET | /api/pdf-treatment/health | Status do serviço |
Convenção de Nomenclatura (e-ARQ Brasil)
O sistema gera nomes de PDF automaticamente seguindo o padrão arquivístico:
| Tipo | Padrão | Exemplo |
|---|---|---|
| Individual | subpasta_nomeimagem_data.pdf | JAPON_VII_BIENAL1964_foto001_20251230.pdf |
| Concatenado | subpasta_agrupado_data.pdf | JAPON_VII_BIENAL1964_agrupado_20251230.pdf |
Função de geração de nome (pdfTreatmentRoute.js):
const generatePdfFileName = (filePath, fileName, isConcatenated = false) => {
// Extrair nome da subpasta (última pasta antes do arquivo)
let subfolderName = '';
if (filePath) {
const pathParts = filePath.split('/').filter(p => p);
if (pathParts.length >= 2) {
const lastPart = pathParts[pathParts.length - 1];
if (lastPart.includes('.')) {
subfolderName = pathParts[pathParts.length - 2];
} else {
subfolderName = lastPart;
}
}
}
// Limpar nome da subpasta
subfolderName = subfolderName.replace(/[^\w\s\u00C0-\u017F-]/g, '').trim() || 'documento';
// Data atual formatada YYYYMMDD
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
if (isConcatenated) {
return `${subfolderName}_agrupado_${dateStr}.pdf`;
} else {
const imageNameWithoutExt = fileName.replace(/\.[^.]+$/, '').replace(/[^\w\s\u00C0-\u017F-]/g, '_');
return `${subfolderName}_${imageNameWithoutExt}_${dateStr}.pdf`;
}
};
Metadados XMP (e-ARQ Brasil)
O sistema adiciona metadados XMP ao PDF usando a biblioteca pikepdf:
| Campo XMP | Descrição | Origem |
|---|---|---|
dc:creator | Responsável pela digitalização | Email do usuário logado ou editado |
dc:description | Descrição do documento | Gerado automaticamente |
dc:format | Formato MIME | application/pdf |
xmp:CreateDate | Data de criação/digitalização | EXIF da imagem ou data atual |
xmp:ModifyDate | Data de modificação | Data da conversão |
xmp:CreatorTool | Software de criação | Clio SIGAD - Digitalização ({equipamento}) |
pdf:Producer | Produtor do PDF | Clio SIGAD - ArboreoLab |
pdf:Keywords | Palavras-chave | digitalização;{equipamento};DPI:{dpi} |
Exemplo de código Python para adicionar XMP:
import pikepdf
with pikepdf.open(output_path, allow_overwriting_input=True) as pdf:
with pdf.open_metadata() as xmp:
xmp['dc:creator'] = [metadata.get('responsavel', 'Sistema Clio')]
xmp['xmp:CreateDate'] = data_digitalizacao
xmp['xmp:ModifyDate'] = datetime.now().isoformat()
xmp['xmp:CreatorTool'] = f'Clio SIGAD - Digitalização ({equipamento})'
xmp['pdf:Producer'] = 'Clio SIGAD - ArboreoLab'
pdf.save(output_path)
Extração de Metadados EXIF
O sistema extrai automaticamente metadados EXIF das imagens:
from PIL import Image
from PIL.ExifTags import TAGS
img = Image.open(image_path)
exif_data = {}
if hasattr(img, '_getexif') and img._getexif():
raw_exif = img._getexif()
for tag_id, value in raw_exif.items():
tag = TAGS.get(tag_id, tag_id)
exif_data[tag] = value
# Campos importantes:
# - DateTimeOriginal: Data da foto
# - Make / Model: Equipamento
# - XResolution / YResolution: DPI
Estrutura de Pastas (1 → 3)
O sistema preserva a estrutura de subpastas, movendo de 1 - Documentos originais para 3 - Documentos PDF - final:
Acervo/Coleções/COLECAO/usuario@email.com/
├── 1 - Documentos originais/
│ └── @JAPON VII BIENAL1964/
│ ├── foto001.jpg
│ ├── foto002.jpg
│ └── foto003.jpg
│
└── 3 - Documentos PDF - final/ ← PDFs gerados aqui
└── @JAPON VII BIENAL1964/ ← Mesma estrutura de subpastas
├── JAPON_VII_BIENAL1964_foto001_20251230.pdf
└── JAPON_VII_BIENAL1964_agrupado_20251230.pdf
Interface do Usuário (Frontend)
Localização: src/components/gerenciadordearquivos/gerenciadortratamentopdf.vue
Funcionalidades:
- Navegação em árvore de pastas (filtrada para "1 - Documentos originais")
- Seleção múltipla de imagens (checkbox)
- Preview de miniaturas
- Opção de concatenar múltiplas imagens em um único PDF
- Formulário de edição de metadados XMP (expansível)
- Barra de progresso durante conversão
- Feedback visual de sucesso/erro por arquivo
Estados TypeScript:
// Metadados XMP para e-ARQ Brasil
const xmpMetadata = reactive({
responsavel: '',
dataDigitalizacao: '',
equipamento: '',
compressao: 'JPEG 95%',
useDefaults: true
})
// Opção de concatenação
const concatenateOption = ref<'ask' | 'separate' | 'concatenate'>('ask')
const concatenatedPdfName = ref('')
Dependências Python
O sistema requer as seguintes bibliotecas Python (instaladas no venv):
source /home/arboreolab/estudos/.venv/bin/activate
pip install Pillow pikepdf
- Pillow: Manipulação de imagens, conversão para PDF, extração EXIF
- pikepdf: Manipulação de PDFs, adição de metadados XMP
Troubleshooting
| Problema | Causa | Solução |
|---|---|---|
| XMP não adicionado | pikepdf não instalado | pip install pikepdf |
| Erro "Cannot read properties of undefined" | generatePdfFileName retorna string, não objeto | Verificar uso direto da string |
| PDF salvo na raiz do Drive | filePath chegando como undefined | Verificar propagação de path na árvore |
| Imagem não convertida | Formato não suportado | Verificar se é JPG/PNG/TIFF |
| DPI baixo no PDF | Imagem original com DPI < 300 | Sistema força mínimo de 300 DPI |
Logs de Debug
# Ver logs de conversão de PDF
pm2 logs node-backend-Arboreolab --lines 100 | grep -E "\[PDF\]"
# Saída esperada para conversão bem-sucedida:
[PDF] Requisição de conversão: { fileId, fileName, filePath, driveEmail }
[PDF] Baixando imagem do Drive...
[PDF] Arquivo baixado: /path/to/temp/input_xxx.jpg
[PDF] Metadados XMP: { responsavel, dataDigitalizacao, equipamento, compressao }
[PDF] Convertendo para PDF...
[PDF] PDF criado: /path/to/temp/output_xxx.pdf | XMP: true
[PDF] Pasta de destino: .../3 - Documentos PDF - final/subpasta
[PDF] Enviando PDF para o Drive...
[PDF] PDF enviado com sucesso: {driveFileId}
Fluxo Completo de Conversão
1. Usuário seleciona imagens no gerenciador de arquivos
2. Clica em "Converter para PDF"
3. Modal exibe opções:
- Converter separadamente (1 PDF por imagem)
- Concatenar em um único PDF
4. Usuário pode editar metadados XMP (opcional)
5. Clica em "Iniciar Conversão"
6. Backend:
a. Baixa imagem(ns) do Google Drive
b. Extrai metadados EXIF
c. Converte para PDF/A com Pillow
d. Adiciona metadados XMP com pikepdf
e. Cria estrutura de pastas em "3 - Documentos PDF - final"
f. Faz upload do PDF para o Drive
g. Limpa arquivos temporários
7. Webhook do Google Drive sincroniza tabela automaticamente
8. Frontend exibe resultado de sucesso
🔐 Sistema de RBAC (Role-Based Access Control)
O sistema Clio implementa um controle de acesso baseado em papéis (RBAC) interno e contextual, onde as permissões são avaliadas no contexto de cada projeto.
Arquitetura do Sistema RBAC
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE RBAC CONTEXTUAL │
│ │
│ HIERARQUIA DE PAPÉIS (níveis numéricos) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ owner (100) ─► admin (90) ─► gestor (50) ─► usuario (10) ─► visitante (0)│
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ FLUXO DE AUTORIZAÇÃO │
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Requisição │───►│ verifyJwt.js │───►│ selectDatabase.js │ │
│ │ HTTP + JWT │ │ (autenticação) │ │ (injetar projectInfo)│ │
│ └──────────────┘ └──────────────────┘ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ verifyPermissions.js │ │
│ │ (avaliar role/level) │ │
│ └──────────────────────┘ │
│ │ │
│ ┌────────────────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ 403 Denied │ │ Autorizado │ │ Fallback │ │
│ │ (nível │ │ (continua │ │ (buscar │ │
│ │ insufic.) │ │ rota) │ │ role DB) │ │
│ └────────────┘ └────────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Tabela de Papéis e Níveis
| Papel | Nível | Descrição | Permissões |
|---|---|---|---|
owner | 100 | Proprietário do projeto | Tudo, incluindo excluir projeto e transferir propriedade |
admin | 90 | Administrador | Gerenciar equipe, alterar configurações, excluir dados |
gestor | 50 | Gestor de conteúdo | Editar, aprovar, publicar documentos |
usuario | 10 | Usuário colaborador | Criar, editar próprios documentos |
visitante | 0 | Acesso somente leitura | Apenas visualizar |
Tabelas do Banco de Dados (ArboreolabADM)
O RBAC utiliza as seguintes tabelas no database central ArboreolabADM:
arb_usuarios - Cadastro de usuários:
CREATE TABLE arb_usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nome VARCHAR(255),
organizacao VARCHAR(255),
status ENUM('pendente','ativo','suspenso','inativo') DEFAULT 'pendente',
role ENUM('admin','gestor','usuario','visitante') DEFAULT 'usuario', -- role global
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
arb_projetos - Projetos cadastrados:
CREATE TABLE arb_projetos (
id INT AUTO_INCREMENT PRIMARY KEY,
usuario_id INT NOT NULL, -- proprietário (owner)
nome VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
database_name VARCHAR(100) NOT NULL,
status ENUM('configurando','ativo','pausado','arquivado') DEFAULT 'configurando',
FOREIGN KEY (usuario_id) REFERENCES arb_usuarios(id)
);
arb_projeto_usuarios - Membros da equipe (relacionamento N:N):
CREATE TABLE arb_projeto_usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
projeto_id INT NOT NULL,
usuario_id INT NOT NULL,
role ENUM('admin','gestor','usuario','visitante') DEFAULT 'usuario',
status ENUM('pendente','ativo','suspenso','removido') DEFAULT 'pendente',
convidado_por INT,
convite_aceito_em TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (projeto_id) REFERENCES arb_projetos(id),
FOREIGN KEY (usuario_id) REFERENCES arb_usuarios(id),
UNIQUE KEY uk_projeto_usuario (projeto_id, usuario_id)
);
Coluna computada computed_role:
-- Adicionada para consultas otimizadas
ALTER TABLE arb_projeto_usuarios
ADD COLUMN computed_role TINYINT UNSIGNED GENERATED ALWAYS AS (
CASE role
WHEN 'admin' THEN 90
WHEN 'gestor' THEN 50
WHEN 'usuario' THEN 10
WHEN 'visitante' THEN 0
ELSE 0
END
) VIRTUAL;
Middleware de Autorização
Localização: node/backend/middleware/verifyPermissions.js
Funções disponíveis:
| Função | Nível Mínimo | Uso |
|---|---|---|
requireOwner() | 100 | Excluir projeto, transferir propriedade |
requireAdmin() | 90 | Gerenciar equipe, configurações |
requireManage() | 50 | Editar qualquer documento, aprovar |
requireWrite() | 10 | Criar/editar próprios documentos |
requireRead() | 0 | Visualizar (público para autenticados) |
⚠️ IMPORTANTE: Sempre invocar com parênteses!
// ✅ CORRETO - Middleware invocado como função
router.post('/:id/equipe/convidar', requireAdmin(), async (req, res) => { ... });
// ❌ ERRADO - Passa referência da função, não executa
router.post('/:id/equipe/convidar', requireAdmin, async (req, res) => { ... });
Implementação do middleware:
// filepath: node/backend/middleware/verifyPermissions.js
const { getAdminPool } = require('./selectDatabase');
const ROLE_LEVELS = {
owner: 100,
admin: 90,
gestor: 50,
usuario: 10,
visitante: 0
};
/**
* Busca o papel do usuário em um projeto específico
*/
async function fetchUserRole(userEmail, projectId) {
const pool = getAdminPool();
const connection = await pool.getConnection();
try {
// Verificar se é owner
const [ownerCheck] = await connection.execute(`
SELECT p.id, u.email
FROM arb_projetos p
JOIN arb_usuarios u ON p.usuario_id = u.id
WHERE p.id = ? AND u.email = ?
`, [projectId, userEmail]);
if (ownerCheck.length > 0) {
return { role: 'owner', level: 100 };
}
// Verificar papel como membro
const [memberCheck] = await connection.execute(`
SELECT pu.role, pu.computed_role
FROM arb_projeto_usuarios pu
JOIN arb_usuarios u ON pu.usuario_id = u.id
WHERE pu.projeto_id = ? AND u.email = ? AND pu.status = 'ativo'
`, [projectId, userEmail]);
if (memberCheck.length > 0) {
return {
role: memberCheck[0].role,
level: memberCheck[0].computed_role || ROLE_LEVELS[memberCheck[0].role] || 0
};
}
return { role: 'visitante', level: 0 };
} finally {
connection.release();
}
}
/**
* Factory para criar middleware de verificação de permissão
*/
function requireLevel(minLevel, roleName) {
return async function(req, res, next) {
try {
const userEmail = req.user?.email;
const projectId = req.projectInfo?.id || req.params.id || req.body.projectId;
if (!userEmail) {
return res.status(401).json({
success: false,
error: 'Usuário não autenticado'
});
}
if (!projectId) {
return res.status(400).json({
success: false,
error: 'ID do projeto não informado'
});
}
// Tentar obter role do projectInfo injetado ou buscar do DB
let userRole, userLevel;
if (req.projectInfo?.userRole) {
userRole = req.projectInfo.userRole;
userLevel = req.projectInfo.userLevel ?? ROLE_LEVELS[userRole] ?? 0;
} else {
const roleInfo = await fetchUserRole(userEmail, projectId);
userRole = roleInfo.role;
userLevel = roleInfo.level;
}
if (userLevel < minLevel) {
console.warn(`[verifyPermissions] ⛔ Acesso negado: ${userEmail} tem ${userRole}(${userLevel}), precisa de ${roleName}(${minLevel})`);
return res.status(403).json({
success: false,
error: `Permissão insuficiente. Requer: ${roleName}`,
required: roleName,
current: userRole
});
}
// Injetar informações de permissão na requisição
req.userPermissions = {
role: userRole,
level: userLevel,
isOwner: userRole === 'owner',
isAdmin: userLevel >= 90,
canManage: userLevel >= 50,
canWrite: userLevel >= 10
};
next();
} catch (error) {
console.error('[verifyPermissions] Erro:', error);
res.status(500).json({
success: false,
error: 'Erro ao verificar permissões'
});
}
};
}
module.exports = {
requireOwner: () => requireLevel(100, 'owner'),
requireAdmin: () => requireLevel(90, 'admin'),
requireManage: () => requireLevel(50, 'gestor'),
requireWrite: () => requireLevel(10, 'usuario'),
requireRead: () => requireLevel(0, 'visitante'),
ROLE_LEVELS,
fetchUserRole
};
Uso nas Rotas
Exemplo em projetoRoute.js:
const { requireAdmin, requireManage, requireOwner } = require('../middleware/verifyPermissions');
// Convidar membro - requer admin
router.post('/:id/equipe/convidar', requireAdmin(), async (req, res) => {
// req.userPermissions disponível aqui
const { email, role } = req.body;
// ... lógica de convite
});
// Remover membro - requer admin
router.delete('/:id/equipe/:membroId', requireAdmin(), async (req, res) => {
// ...
});
// Excluir projeto - requer owner
router.delete('/:id', requireOwner(), async (req, res) => {
// ...
});
// Atualizar documento - requer gestor
router.patch('/:id/documento/:docId', requireManage(), async (req, res) => {
// ...
});
Frontend - Composable usePermissions
Localização: iface-frontend-vuejs/src/composables/usePermissions.ts
import { computed } from 'vue'
import { useProjetoStore } from '@/stores/projeto'
export function usePermissions() {
const projetoStore = useProjetoStore()
const userRole = computed(() => projetoStore.projeto?.minha_role || 'visitante')
const roleLevel = computed(() => {
const levels: Record<string, number> = {
owner: 100,
admin: 90,
gestor: 50,
usuario: 10,
visitante: 0
}
return levels[userRole.value] || 0
})
const isOwner = computed(() => userRole.value === 'owner')
const isAdmin = computed(() => roleLevel.value >= 90)
const canManage = computed(() => roleLevel.value >= 50)
const canWrite = computed(() => roleLevel.value >= 10)
const canRead = computed(() => roleLevel.value >= 0)
const can = (action: string): boolean => {
const requirements: Record<string, number> = {
'project.delete': 100,
'project.settings': 90,
'team.invite': 90,
'team.remove': 90,
'document.approve': 50,
'document.edit': 10,
'document.view': 0
}
return roleLevel.value >= (requirements[action] || 100)
}
return {
userRole,
roleLevel,
isOwner,
isAdmin,
canManage,
canWrite,
canRead,
can
}
}
Uso no template Vue:
<script setup>
import { usePermissions } from '@/composables/usePermissions'
const { isAdmin, canManage, can } = usePermissions()
</script>
<template>
<!-- Botão só aparece para admins -->
<button v-if="isAdmin" @click="openTeamSettings">
Gerenciar Equipe
</button>
<!-- Área de edição só para gestores+ -->
<div v-if="canManage" class="edit-section">
<MetadataEditor :documento="doc" />
</div>
<!-- Verificação granular de ação -->
<button
v-if="can('team.invite')"
@click="inviteMember"
>
Convidar Membro
</button>
</template>
Interceptor Axios para 403
Localização: iface-frontend-vuejs/src/api/axios.ts
import { useToast } from '@/composables/useToast'
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
const { showToast } = useToast()
const errorData = error.response.data
showToast(
'error',
'Acesso Negado',
errorData.error || 'Você não tem permissão para esta ação'
)
console.warn('[Axios] 403 Forbidden:', errorData)
}
return Promise.reject(error)
}
)
Troubleshooting RBAC
| Problema | Causa | Solução |
|---|---|---|
| 403 em todas as rotas | Middleware sem parênteses | Alterar requireAdmin para requireAdmin() |
req.userPermissions undefined | Middleware não executou | Verificar ordem: verifyJwt → selectDatabase → requireX() |
| Usuário com role errado | Cache desatualizado | Forçar refresh do projetoStore |
| Owner aparece como admin | Query não verifica arb_projetos.usuario_id | Usar fetchUserRole() corrigido |
| Erro "SELECT denied for table" | Permissões MySQL | GRANT SELECT ON ArboreolabADM.* TO 'user'@'localhost' |
Logs de Debug
# Ver logs de autorização
pm2 logs node-backend-Arboreolab --lines 100 | grep -E "\[verifyPermissions\]"
# Saída esperada:
[verifyPermissions] ✓ Acesso autorizado: user@email.com (admin/90) >= gestor/50
[verifyPermissions] ⛔ Acesso negado: user@email.com tem usuario(10), precisa de admin(90)
📋 SUGESTÕES PARA O GERENCIAMENTO DE USUÁRIOS (ASSINATURAS, CONVITES, PERMISSÕES)
1. Fluxo de Convites - Melhorias Sugeridas
1.1 Expiração de Convites
Atualmente os convites ficam em status pendente indefinidamente. Sugestões:
-- Adicionar coluna de expiração
ALTER TABLE arb_projeto_usuarios
ADD COLUMN convite_expira_em TIMESTAMP NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL 7 DAY);
-- Índice para consultas de expiração
CREATE INDEX idx_convite_expira ON arb_projeto_usuarios(convite_expira_em)
WHERE status = 'pendente';
Implementação de CRON para limpar convites expirados:
// Executar diariamente às 00:00
cron.schedule('0 0 * * *', async () => {
const connection = await getAdminPool().getConnection();
try {
await connection.execute(`
UPDATE arb_projeto_usuarios
SET status = 'removido'
WHERE status = 'pendente'
AND convite_expira_em < NOW()
`);
} finally {
connection.release();
}
});
1.2 Notificação por Email
Integrar com serviço de email para notificar convidados:
// Após criar convite
await enviarEmailConvite({
destinatario: novoUsuario.email,
nomeConvidador: req.user.nome,
nomeProjeto: projeto.nome,
linkAceitacao: `${BASE_URL}/projeto/aceitar-convite?token=${token}&projeto=${projetoId}`,
expiraEm: '7 dias'
});
1.3 Convite com Link Único
Gerar token seguro para aceitar convite sem login prévio:
const crypto = require('crypto');
function gerarTokenConvite(email, projetoId, role) {
const payload = {
email,
projetoId,
role,
exp: Date.now() + (7 * 24 * 60 * 60 * 1000) // 7 dias
};
const token = crypto.randomBytes(32).toString('hex');
// Armazenar token no banco associado ao convite
return token;
}
2. Sistema de Assinaturas - Implementação Sugerida
2.1 Estrutura da Tabela de Planos
CREATE TABLE arb_planos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(50) NOT NULL, -- 'gratuito', 'basico', 'profissional', 'enterprise'
slug VARCHAR(50) NOT NULL UNIQUE,
preco_mensal DECIMAL(10,2) DEFAULT 0,
limite_usuarios INT DEFAULT 1,
limite_documentos INT DEFAULT 1000,
limite_storage_gb DECIMAL(10,2) DEFAULT 5,
limite_ocr_mensal INT DEFAULT 500,
features JSON, -- recursos adicionais
ativo TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Inserir planos padrão
INSERT INTO arb_planos (nome, slug, preco_mensal, limite_usuarios, limite_documentos, limite_storage_gb, limite_ocr_mensal, features) VALUES
('Gratuito', 'gratuito', 0, 1, 1000, 5, 500, '{"ocr": true, "ner": false, "busca_semantica": false}'),
('Básico', 'basico', 49.90, 3, 5000, 20, 2000, '{"ocr": true, "ner": true, "busca_semantica": false}'),
('Profissional', 'profissional', 149.90, 10, 50000, 100, 10000, '{"ocr": true, "ner": true, "busca_semantica": true}'),
('Enterprise', 'enterprise', 499.90, -1, -1, -1, -1, '{"ocr": true, "ner": true, "busca_semantica": true, "api_access": true}');
2.2 Verificação de Limites
// Middleware para verificar limites do plano
async function verificarLimitePlano(tipo) {
return async (req, res, next) => {
const projetoId = req.projectInfo?.id || req.params.id;
const connection = await getAdminPool().getConnection();
try {
const [result] = await connection.execute(`
SELECT
a.limite_usuarios, a.limite_documentos, a.limite_storage_gb,
a.uso_documentos, a.uso_storage_gb,
(SELECT COUNT(*) FROM arb_projeto_usuarios WHERE projeto_id = ? AND status = 'ativo') as usuarios_ativos
FROM arb_assinaturas a
WHERE a.projeto_id = ? AND a.status = 'ativo'
`, [projetoId, projetoId]);
if (result.length === 0) {
return res.status(403).json({ error: 'Assinatura não encontrada' });
}
const assinatura = result[0];
switch (tipo) {
case 'usuarios':
if (assinatura.limite_usuarios !== -1 &&
assinatura.usuarios_ativos >= assinatura.limite_usuarios) {
return res.status(403).json({
error: 'Limite de usuários atingido',
limite: assinatura.limite_usuarios,
atual: assinatura.usuarios_ativos
});
}
break;
case 'documentos':
if (assinatura.limite_documentos !== -1 &&
assinatura.uso_documentos >= assinatura.limite_documentos) {
return res.status(403).json({
error: 'Limite de documentos atingido',
limite: assinatura.limite_documentos,
atual: assinatura.uso_documentos
});
}
break;
}
req.assinatura = assinatura;
next();
} finally {
connection.release();
}
};
}
// Uso na rota de convite
router.post('/:id/equipe/convidar',
requireAdmin(),
verificarLimitePlano('usuarios'),
async (req, res) => { ... }
);
3. Permissões Granulares - Modelo ACL
3.1 Tabela de Permissões por Recurso
CREATE TABLE arb_permissoes_recursos (
id INT AUTO_INCREMENT PRIMARY KEY,
projeto_id INT NOT NULL,
usuario_id INT NOT NULL,
recurso_tipo ENUM('colecao', 'pasta', 'documento') NOT NULL,
recurso_id VARCHAR(255) NOT NULL, -- ID do recurso (pode ser Drive ID, doc ID, etc)
permissao ENUM('read', 'write', 'manage', 'none') DEFAULT 'read',
herdado TINYINT(1) DEFAULT 0, -- se herdou de pasta pai
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (projeto_id) REFERENCES arb_projetos(id),
FOREIGN KEY (usuario_id) REFERENCES arb_usuarios(id),
UNIQUE KEY uk_recurso_usuario (projeto_id, recurso_tipo, recurso_id, usuario_id)
);
3.2 Função de Verificação de Permissão por Recurso
async function verificarPermissaoRecurso(usuarioId, projetoId, recursoTipo, recursoId) {
const connection = await getAdminPool().getConnection();
try {
// Primeiro verifica permissão específica
const [especifica] = await connection.execute(`
SELECT permissao FROM arb_permissoes_recursos
WHERE projeto_id = ? AND usuario_id = ?
AND recurso_tipo = ? AND recurso_id = ?
`, [projetoId, usuarioId, recursoTipo, recursoId]);
if (especifica.length > 0) {
return especifica[0].permissao;
}
// Se não encontrou, verifica permissão herdada da pasta pai
if (recursoTipo === 'documento' || recursoTipo === 'pasta') {
// Buscar pasta pai e verificar recursivamente
// ...implementação de herança
}
// Fallback para role do projeto
const [role] = await connection.execute(`
SELECT role FROM arb_projeto_usuarios
WHERE projeto_id = ? AND usuario_id = ? AND status = 'ativo'
`, [projetoId, usuarioId]);
if (role.length > 0) {
return role[0].role === 'admin' ? 'manage' :
role[0].role === 'gestor' ? 'write' : 'read';
}
return 'none';
} finally {
connection.release();
}
}
4. Auditoria e Logs de Segurança
4.1 Tabela de Logs de Acesso
CREATE TABLE arb_logs_acesso (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
projeto_id INT,
usuario_id INT,
acao VARCHAR(100) NOT NULL,
recurso_tipo VARCHAR(50),
recurso_id VARCHAR(255),
resultado ENUM('sucesso', 'negado', 'erro') NOT NULL,
ip_origem VARCHAR(45),
user_agent VARCHAR(500),
detalhes JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_projeto_data (projeto_id, created_at),
INDEX idx_usuario_data (usuario_id, created_at),
INDEX idx_acao_resultado (acao, resultado)
);
4.2 Middleware de Auditoria
function logAcesso(acao) {
return async (req, res, next) => {
const inicio = Date.now();
// Capturar resposta original
const originalSend = res.send;
res.send = function(body) {
const duracao = Date.now() - inicio;
const statusCode = res.statusCode;
const resultado = statusCode >= 200 && statusCode < 400 ? 'sucesso' :
statusCode === 403 ? 'negado' : 'erro';
// Log assíncrono
registrarLogAcesso({
projeto_id: req.projectInfo?.id,
usuario_id: req.user?.id,
acao,
recurso_tipo: req.params.tipo,
recurso_id: req.params.id,
resultado,
ip_origem: req.ip,
user_agent: req.get('User-Agent'),
detalhes: { duracao, statusCode }
}).catch(console.error);
return originalSend.call(this, body);
};
next();
};
}
// Uso
router.delete('/:id', logAcesso('projeto.excluir'), requireOwner(), async (req, res) => { ... });
5. UI/UX - Componentes de Gerenciamento
5.1 Componente de Gerenciamento de Equipe
<!-- GerenciadorEquipe.vue -->
<template>
<div class="team-manager">
<!-- Cabeçalho com contador de limite -->
<div class="team-header">
<h3>Equipe do Projeto</h3>
<span class="limit-badge" :class="{ 'near-limit': isNearLimit }">
{{ membrosAtivos }}/{{ limiteUsuarios === -1 ? '∞' : limiteUsuarios }}
</span>
</div>
<!-- Lista de membros -->
<div class="member-list">
<div v-for="membro in membros" :key="membro.id" class="member-card">
<Avatar :src="membro.avatar_url" :initials="getInitials(membro)" />
<div class="member-info">
<strong>{{ membro.nome || membro.email }}</strong>
<span class="role-badge" :class="'role-' + membro.role">
{{ getRoleLabel(membro.role, membro.isOwner) }}
</span>
</div>
<!-- Ações (só para admins) -->
<div v-if="isAdmin && !membro.isOwner" class="member-actions">
<select v-model="membro.role" @change="alterarRole(membro)">
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="usuario">Usuário</option>
<option value="visitante">Visitante</option>
</select>
<button @click="removerMembro(membro)" class="btn-danger-sm">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
<!-- Formulário de convite -->
<div v-if="canInvite" class="invite-form">
<input v-model="novoEmail" type="email" placeholder="email@exemplo.com" />
<select v-model="novoRole">
<option value="gestor">Gestor</option>
<option value="usuario">Usuário</option>
<option value="visitante">Visitante</option>
</select>
<button @click="enviarConvite" :disabled="isInviting">
{{ isInviting ? 'Enviando...' : 'Convidar' }}
</button>
</div>
<!-- Aviso de limite -->
<div v-if="isAtLimit" class="limit-warning">
<i class="bi bi-exclamation-triangle"></i>
Limite de usuários atingido.
<a href="/upgrade">Atualize seu plano</a> para adicionar mais membros.
</div>
</div>
</template>
5.2 Dashboard de Uso (Assinatura)
<!-- AssinaturaUsage.vue -->
<template>
<div class="usage-dashboard">
<h3>Uso do Plano: {{ planoNome }}</h3>
<div class="usage-cards">
<!-- Documentos -->
<div class="usage-card">
<div class="usage-icon"><i class="bi bi-file-earmark"></i></div>
<div class="usage-info">
<span class="usage-label">Documentos</span>
<div class="usage-bar">
<div class="usage-fill" :style="{ width: docPercentage + '%' }"></div>
</div>
<span class="usage-numbers">
{{ formatNumber(usoDocumentos) }} / {{ limiteDocumentos === -1 ? '∞' : formatNumber(limiteDocumentos) }}
</span>
</div>
</div>
<!-- Storage -->
<div class="usage-card">
<div class="usage-icon"><i class="bi bi-hdd"></i></div>
<div class="usage-info">
<span class="usage-label">Armazenamento</span>
<div class="usage-bar">
<div class="usage-fill" :style="{ width: storagePercentage + '%' }"></div>
</div>
<span class="usage-numbers">
{{ formatBytes(usoStorage) }} / {{ limiteStorage === -1 ? '∞' : formatBytes(limiteStorage) }}
</span>
</div>
</div>
<!-- OCR Mensal -->
<div class="usage-card">
<div class="usage-icon"><i class="bi bi-eye"></i></div>
<div class="usage-info">
<span class="usage-label">OCR (mensal)</span>
<div class="usage-bar">
<div class="usage-fill" :style="{ width: ocrPercentage + '%' }"></div>
</div>
<span class="usage-numbers">
{{ formatNumber(usoOcr) }} / {{ limiteOcr === -1 ? '∞' : formatNumber(limiteOcr) }}
</span>
</div>
</div>
</div>
<!-- Upgrade CTA -->
<div v-if="showUpgradePrompt" class="upgrade-prompt">
<p>Você está usando {{ maxUsagePercentage }}% dos recursos do plano.</p>
<button @click="goToUpgrade" class="btn-primary">
Fazer Upgrade
</button>
</div>
</div>
</template>
6. Checklist de Implementação Futura
Fase 1: Convites Aprimorados
- Adicionar expiração de convites (7 dias)
- Implementar CRON de limpeza de convites expirados
- Integrar envio de email com template
- Criar página de aceite de convite com preview do projeto
Fase 2: Assinaturas e Limites
- Criar tabela
arb_planoscom planos padrão - Implementar middleware
verificarLimitePlano - Adicionar contadores em tempo real no dashboard
- Criar alertas de proximidade de limite (80%, 90%, 100%)
Fase 3: Permissões Granulares
- Criar tabela
arb_permissoes_recursos - Implementar herança de permissões de pastas
- Adicionar UI de gerenciamento de permissões por pasta/coleção
- Integrar com Google Drive ACL
Fase 4: Auditoria
- Criar tabela
arb_logs_acesso - Implementar middleware de log
- Criar dashboard de auditoria para admins
- Implementar exportação de logs
Fase 5: Integração de Pagamentos
- Integrar gateway de pagamento (Stripe/PagSeguro)
- Criar fluxo de upgrade/downgrade de plano
- Implementar período de trial
- Configurar webhooks de pagamento
📚 Referências RBAC
- NIST RBAC Model
- OWASP Access Control Cheat Sheet
- Express.js Middleware Best Practices
- Vue.js Composition API - Composables
🚀 ESTÁGIO ATUAL DO PROJETO (Janeiro 2026)
Sistema de Produção OCR - Worker Híbrido (Vision-MacOS)
O sistema de produção OCR utiliza uma arquitetura híbrida com um Worker remoto rodando em MacOS para acessar a API Vision da Apple.
Arquitetura Implementada
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARQUITETURA DE PRODUÇÃO OCR │
│ │
│ SERVIDOR LINUX (srv1.arboreolab.com.br) │
│ ├── Node.js Backend (PM2) │
│ │ ├── /api/workers - Gerenciamento de workers │
│ │ ├── /api/workers/jobs - Fila de jobs │
│ │ └── /api/gerenciador-dados/ocr/* - Status de produção │
│ │ │
│ └── Túnel SSH Reverso (porta 9001) │
│ │ │
│ ▼ │
│ MAC WORKER (via SSH -R 9001:localhost:8766) │
│ ├── FastAPI Server (porta 8766) │
│ ├── ocrmac (Apple Vision Framework) │
│ └── Credenciais Google Drive (token.json) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Tabelas do Sistema de Workers (arboreolabconn)
| Tabela | Função |
|---|---|
worker_registry | Cadastro de workers (mac-vision-01, etc) |
job_queue | Fila de processamento com status |
Endpoints de Produção OCR Implementados
| Endpoint | Método | Descrição |
|---|---|---|
/api/workers | GET | Lista workers registrados e status |
/api/workers/jobs/queue | GET | Lista fila de jobs |
/api/workers/jobs | POST | Cria novo job na fila |
/api/workers/jobs/:id/dispatch | POST | Despacha job para worker |
/api/workers/jobs/:id | DELETE | Remove job da fila |
/api/workers/jobs/cleanup-stale | POST | Marca jobs antigos como failed |
/api/gerenciador-dados/ocr/production-status | GET | Status de OCR por coleção |
/api/gerenciador-dados/ocr/pending-files | GET | Arquivos pendentes de OCR |
Estado dos Dados (GeopoliticasVector)
| Métrica | Valor | Observação |
|---|---|---|
Total em gerenciador_dados | 20.072 | Documentos catalogados |
Com url_jpg (imagens) | 18.242 | Imagens brutas |
Com url_pdf (PDFs) | 1.691 | PDFs tratados |
Registros em clio_ocr | 16.117 | OCRs processados |
OCRs com conteúdo (qtde_caracteres > 100) | 0 | ⚠️ Todos vazios |
Pendentes (status_ocr NULL) | 3.453 | Precisam de OCR |
Critérios para Identificar Arquivos Pendentes de OCR
-- Opção 1: Via status_ocr em gerenciador_dados
SELECT db_id, COALESCE(url_pdf, url_jpg) as file_id, conteudo_original_nome_arquivo
FROM gerenciador_dados
WHERE (status_ocr IS NULL OR status_ocr = '')
AND (url_pdf IS NOT NULL OR url_jpg IS NOT NULL);
-- Opção 2: Via JOIN com clio_ocr (sem registro)
SELECT gd.db_id, COALESCE(gd.url_pdf, gd.url_jpg) as file_id
FROM gerenciador_dados gd
LEFT JOIN clio_ocr co ON co.url_id = COALESCE(gd.url_pdf, gd.url_jpg)
WHERE co.id IS NULL
AND (gd.url_pdf IS NOT NULL OR gd.url_jpg IS NOT NULL);
-- Opção 3: OCRs sem conteúdo (reprocessar)
SELECT gd.db_id, COALESCE(gd.url_pdf, gd.url_jpg) as file_id
FROM gerenciador_dados gd
INNER JOIN clio_ocr co ON co.url_id = COALESCE(gd.url_pdf, gd.url_jpg)
WHERE co.qtde_caracteres IS NULL OR co.qtde_caracteres < 100;
Geração de Markdown Layout-Aware
O worker Python gera Markdown estruturado a partir dos blocos OCR, preservando layout de colunas e detectando hierarquia de títulos.
Função principal: blocks_to_markdown() em worker_server.py
Recursos implementados:
- Detecção de duas colunas (threshold x=0.48)
- Agrupamento de parágrafos por proximidade vertical
- Inferência de títulos (CAPS + curtos + alta confiança)
- Mínimo de 5 caracteres para evitar falsos positivos ("D.", "Sr.")
Padrões de Catálogo de Arte (CATALOG_PATTERNS)
O worker inclui padrões especializados para processamento de catálogos de arte, integrados em _process_column_to_markdown():
| Tipo | Regex | Exemplo | Formatação |
|---|---|---|---|
| ARTISTA | ^[A-ZÁÉÍ]{2,}(?:\s+[A-Za-záéí]+)+$ | IKEDA Masuo | **negrito** |
| DIMENSAO | ^\d+(?:\.\d+)?\s*[Xx×]\s*\d+ | 162 X 130 | *itálico* |
| ANO | ^(18|19|20)\d{2}$ | 1962 | texto simples |
| TECNICA | Lista de palavras-chave | Litografía | *itálico* |
| NUMERO_CATALOGO | ^\d{1,4}\.\s+\S | 33. Orbita | - lista |
| TIRAGEM | ^\d+/\d+$ ou A.P. | 1/50 | texto simples |
| COLECAO | ^(Coleção|Colección|...) | Coleção Particular | texto simples |
| ASSINATURA_OBRA | ^(Ass\.|Assinado|Signed) | Ass. CID | texto simples |
Prioridade de processamento:
1. CATALOG_PATTERNS (artistas, dimensões, técnicas)
↓ não match
2. Heurísticas genéricas (CAPS = título, números = lista)
Isso garante que dimensões (162 X 130) nunca sejam classificadas como títulos (## 162 X 130).
Comandos para Iniciar o Sistema de Produção
No Mac (Worker):
# 1. Ativar ambiente e iniciar FastAPI
cd ~/macos_worker
source .venv/bin/activate
uvicorn main:app --host 0.0.0.0 --port 8766
# 2. Em outro terminal, abrir túnel SSH reverso
ssh -R 9001:localhost:8766 -N arboreolab_38g57g0kO0dh@srv1.arboreolab.com.br
Verificar conectividade (no servidor Linux):
curl http://localhost:9001/health
Disparar job de OCR:
curl -X POST "http://localhost:3000/api/workers/jobs/batch-dispatch" \
-H "Content-Type: application/json" \
-d '{
"worker_id": 1,
"file_ids": ["ID_ARQUIVO_DRIVE_1", "ID_ARQUIVO_DRIVE_2"],
"drive_email": "adm.geopoliticas@gmail.com"
}'
📋 PROPOSTAS DE PROSSEGUIMENTO
Fase 1: Completar Pipeline de OCR (Prioridade Alta)
1.1. Refatorar Endpoint de Arquivos Pendentes
- Modificar
/ocr/pending-filespara usargerenciador_dados.status_ocr - Adicionar filtro por coleção via
colecao(não apenas breadcrumbs) - Retornar
db_idjunto comfile_idpara atualização posterior
1.2. Implementar Callback de OCR Completo
- Endpoint
POST /api/workers/jobs/:id/callbackreceber resultado do Mac - Atualizar
clio_ocrcom conteúdo extraído - Atualizar
gerenciador_dados.status_ocrpara "CONCLUÍDO" - Gravar
qtde_caracterescorretamente
1.3. Interface de Produção OCR (ProducaoOCR.vue)
- Conectar com novos endpoints de status
- Exibir progresso em tempo real (WebSocket ou polling)
- Permitir seleção de coleção e disparo de lote
Fase 2: Robustez do Worker (Prioridade Média)
2.1. Reconexão Automática do Túnel
- Script de monitoramento no Mac (cron ou launchd)
- Retry automático se túnel cair
- Notificação via webhook se worker offline por > 5 min
2.2. Healthcheck Periódico
- CRON no Linux verificando
/healthdo worker - Atualizar
worker_registry.statusautomaticamente - Marcar jobs como "failed" se worker offline durante processamento
2.3. Rate Limiting e Retry
- Implementar retry com backoff exponencial para falhas
- Limite de jobs simultâneos por worker
- Dead letter queue para jobs que falham 3x
Fase 3: Escalabilidade (Prioridade Baixa)
3.1. Múltiplos Workers
- Suportar N workers MacOS simultâneos
- Load balancing round-robin ou por carga
- Dashboard de monitoramento de frota
3.2. Workers Alternativos
- Worker Tesseract (Linux) para fallback
- Worker Google Vision API (cloud)
- Seleção automática baseada em tipo de documento
3.3. Processamento em Lote Otimizado
- Agrupar arquivos pequenos em batches
- Priorização por coleção/urgência
- Estimativa de tempo de conclusão
🔧 ISSUES CONHECIDAS
1. OCRs Vazios (16.117 registros)
Problema: Todos os registros em clio_ocr têm qtde_caracteres = 0
Causa provável: Script de ingestão criou registros mas não processou OCR
Solução: Reprocessar usando o Worker Vision-MacOS
2. Inconsistência status_ocr vs clio_ocr
Problema: gerenciador_dados.status_ocr não está sincronizado com clio_ocr
Solução: Implementar trigger ou callback que atualize ambas as tabelas
3. Túnel SSH Manual
Problema: Túnel precisa ser iniciado manualmente no Mac Solução: Configurar autossh ou launchd para reconexão automática
📅 HISTÓRICO DE ATUALIZAÇÕES
| Data | Alteração |
|---|---|
| 2025-12-31 | Implementados endpoints de produção OCR |
| 2025-12-31 | Adicionado cleanup de jobs stale |
| 2025-12-31 | Mapeamento de critérios de arquivos pendentes |
| 2026-01-02 | Documentação de estágio atual e propostas |