Pular para o conteúdo principal

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.js protects routes. backend/API/googleAuthEndpoint.js handles 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 .ini files in estudos/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 by gerenciadorDriveGoogle.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ém os.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):

CommandArgumentsDescription
check_auth--emailVerify OAuth tokens for user
map--emailFull sync of Drive structure
map_folder--email --folder_idSync specific folder only
upload_file--email --file_path --parent_idUpload file to Drive
create_folder--email --name --parent_idCreate folder in Drive
delete_item--email --file_idDelete file/folder
move_item--email --file_id --new_parent_idMove 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_PASS must 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:
    1. Go to WordPress Admin → Users → Your Profile
    2. Scroll to "Application Passwords"
    3. Enter a name (e.g., "Clio API") and click "Add New"
    4. 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 PathTarget 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

  1. 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"
    }
  2. Test a Tainacan request:

    curl "http://localhost:3000/api/proxy/tainacan/items/91348"
  3. 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.
  4. 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:

  1. Store user's Tainacan credentials in their profile (encrypted)
  2. Pass the target server URL as a header or parameter
  3. Modify proxyRoute.js to 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_isA store 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:

FormatStructureExample
1Array text{ "text": ["linha1", "linha2"] }
2Columns left/right{ "left": [...], "right": [...] }
3Transcription with metadata{ "transcricao_literal": "...", "metadados_documento": {...}, "elementos_nao_textuais": [...] }
4Array of objects[{ "text": "bloco1" }, { "text": "bloco2" }]
5Simple string"texto direto"
6Object with text string{ "text": "texto único" }
7Generic 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:

RoleCSS ClassGradient
owner.role-ownerlinear-gradient(135deg, #ffc107, #fd7e14)
admin.role-adminlinear-gradient(135deg, #6f42c1, #e83e8c)
gestor.role-gestorlinear-gradient(135deg, #007bff, #0056b3)
usuario.role-usuariolinear-gradient(135deg, #28a745, #20c997)
visitante.role-visitantelinear-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)

  1. 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
  2. Start Frontend Dev Server:
    cd /home/arboreolab/Clio/iface-frontend-vuejs
    npm run dev
    Access the application at http://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):
  1. Atualizar código (git pull / commits).
  2. No backend: rebuild/restart PM2
cd /home/arboreolab/Clio/node
pm2 restart node-backend-Arboreolab --update-env
  1. 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
  1. 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:

  1. Incrementa automaticamente a versão no index.html
  2. Registra a alteração em versionamento.log
  3. Executa npm run build-only
  4. Ajusta permissões (755) na pasta dist
  5. Reinicia todos os processos PM2
  6. Reinicia o Nginx para limpar caches

Backend (iface-backend-vuejs/deploy.sh):

cd /home/arboreolab/Clio/iface-backend-vuejs
./deploy.sh

Este script:

  1. Executa npm run build do frontend legado
  2. Copia arquivos para /var/www/arboreolab/iface-backend
  3. Ajusta permissões e proprietário (www-data)
  4. Reinicia o processo PM2 node-backend-Arboreolab
  5. 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:

  1. Add the route in the appropriate route file (e.g., gerenciadorDadosRoute.js)
  2. Restart the backend to apply changes:
    pm2 restart node-backend-Arboreolab --update-env
  3. 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 a metadados JSON 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, hierarchical breadcrumbs, and web_view_link.
  • clio_ocr: Stores the text content extracted from each page of a document. Key fields:
    • workflow_id: Links to gerenciador_dados.db_id
    • url_id: Google Drive file ID (should match id_drive and url_pdf/url_jpg)
    • content: JSON with OCR results in various formats
    • content_markdown: Markdown version of the text
    • ocr_vector: VECTOR(768) for semantic search
    • engine_version: VARCHAR(50) DEFAULT 'legacy' — Motor OCR usado (legacy, vision-macos-v1, bert-ocr-v1)
    • file_hash_sha256: CHAR(64) — Hash SHA256 para deduplicação
    • img_width, img_height: INT — Dimensões da imagem original em pixels
    • technical_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 para clio_ocr.id (CASCADE DELETE)
    • text_content: TEXT — Texto extraído do segmento
    • segment_vector: VECTOR(768) — Embedding do segmento para busca semântica
    • bbox_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 a cluster_id for grouping similar entities. Key fields:
    • entity_name, isA: JSON strings for entity name and type
    • inFileID: Comma-separated list of document IDs where entity appears
    • relatedTo_entity_name, relationshipType: Relationship data
    • semantic_vector: Vector embedding for semantic search
  • Embeddings: Vector embeddings are stored in .npy files (e.g., ClioVector_clio_ocr_embeddings.npy), not in the database. The corresponding metadata is in .json files.

Tabelas de Administração Multi-Tenant (ClioVector)

  • arb_usuarios: Cadastro central de usuários do sistema.

    • id, email (unique), nome, organizacao, telefone, avatar_url
    • google_id: ID do Google OAuth
    • status: 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_name
    • status: 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 convidou
    • convite_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, status
    • limite_documentos, limite_storage_gb, limite_ocr_mensal, limite_usuarios
    • uso_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_novos
    • acao: 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

  1. Python Environment Not Activated: Scripts fail with ModuleNotFoundError. Always run source /home/arboreolab/estudos/.venv/bin/activate first.

  2. Incorrect INI Parsing: Passwords with # are truncated. Ensure any new code reading .ini files uses the custom parseConfigFile function.

  3. CORS Errors: Making API calls to localhost:3000 from the frontend will fail in production. Use relative paths (/api/...).

  4. Tag Splitting: Splitting tag fields by comma will break names. Use the stringToTagsArray utility in the frontend, which correctly handles | and ; separators.

  5. Reactive State in Vue: When accessing Pinia store state in components, wrap it in computed() to ensure reactivity, especially for values like userEmail.

    import { computed } from 'vue';
    import { useAuthStore } from '@/stores/auth';
    const authStore = useAuthStore();
    const userEmail = computed(() => authStore.userEmail);
  6. Environment Variables Not Loading (PM2/dotenv):

    • Symptom: Logs show ⚠️ GOOGLE_CLIENT_ID não configurado no .env even though the .env file is correctly configured.
    • Cause: PM2 caches environment variables. When the .env file is updated, the running process doesn't automatically pick up the changes. Additionally, dotenv.config() must be called before any module that uses process.env is imported.
    • Solution:
      1. Force dotenv to load early: In files that need environment variables (especially those not imported directly by app.js), explicitly load the .env file at the top:
        const path = require('path');
        require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
        // Now process.env.YOUR_VAR is available
      2. Restart PM2 with --update-env: After modifying the .env file, always restart the process with this flag to force PM2 to reload the environment:
        pm2 restart node-backend-Arboreolab --update-env
      3. Verify the path: Ensure the path.resolve() correctly points to the .env file relative to the current script's location (__dirname).
  7. 404 Errors for New API Endpoints:

    • Symptom: Frontend console shows Failed to load resource: the server responded with a status of 404 for 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
  8. 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 }>>([])
  9. MySQL Connection Release:

    • Symptom: connection.release() throws error when using mysql2.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();
  10. Component Import Typos:

    • Symptom: Component not rendering or build warnings.
    • Cause: Typo in component filename (e.g., entidadeserelacioinamentos.vue instead of entidadeserelacionamentos.vue).
    • Solution: Be consistent with filenames. If file has a typo, either rename it or import with the exact typo.
  11. SQL Syntax Error - Trailing Comma:

    • Symptom: ER_PARSE_ERROR with message about syntax near FROM.
    • 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
  12. 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 extractedText computed in gerenciadordeocr.vue and add support for the new format. Common formats include text[], left/right, transcricao_literal, etc.
  13. Tainacan Taxonomy API - Invalid Parameters (400 Bad Request):

    • Symptom: GET /api/proxy/tainacan/taxonomy/{id}/terms returns 400 Bad Request.
    • Cause: The Tainacan taxonomy terms endpoint does NOT accept orderby=name or order=asc parameters.
    • Invalid Request:
      /taxonomy/11944/terms?perpage=1000&hideempty=0&orderby=name&order=asc  → 400 Bad Request
    • Valid Parameters for /taxonomy/{id}/terms:
      ParameterValidDescription
      perpageNumber of terms per page
      hideempty0 = show all, 1 = only with items
      pagedPage number
      searchText search
      parentFilter by parent term
      orderbyNot supported for terms
      orderNot supported for terms
    • Solution: Remove orderby and order parameters, 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' }))
  14. Tainacan Collections API - Invalid orderby Parameter:

    • Symptom: GET /api/proxy/tainacan/collections with orderby=name returns error or unexpected results.
    • Cause: The Tainacan collections endpoint uses title not name for ordering.
    • Solution: Use orderby=title instead of orderby=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' }
      })
  15. 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:
      1. Find all occurrences: grep -n "function functionName" path/to/file.vue
      2. Remove the duplicate declaration (usually keep the more complete version)
      3. Rebuild: npm run build
  16. 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:
      FunctionPurposeSolution
      getFieldSuggestionsGet taxonomy terms for a fieldUse in template preview
      hasFieldSuggestionsCheck if field has suggestionsUse for CSS class binding
      getFieldTaxonomyInfoGet taxonomy info for displayUse in template
      clearDefaultCollectionClear saved collectionAdd button in UI
  17. 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)
      }
  18. 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
  19. 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 via arb_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)
  20. 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ão user_id.
    • Colunas corretas: id, projeto_id, usuario_id, role, status, convidado_por, convite_aceito_em
  21. 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-perfil retorna o plano dentro do array projetos, não como campo assinatura direto.
    • 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ção loadUserData)
  22. 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-projetos só 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)
  23. Database Hardcoded para Template em Vez de Governança (Multi-Tenant):

    • Symptom: Erros Table 'ClioVector.arb_projetos' doesn't exist ou Table 'ClioVector.drive_notification_channels' doesn't exist nos 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 usar ArboreolabADM.

    • Contexto Multi-Tenant:

      DatabaseFunçãoTabelas
      ArboreolabADMGovernança Centralarb_usuarios, arb_projetos, arb_projeto_usuarios, arb_assinaturas, arb_projeto_logs, drive_notification_channels
      arboreolabconnOrquestração Workersworker_registry, job_queue, remote_log
      ClioVectorModelo/TemplateEstrutura de tabelas canônica (todas as tabelas de projeto)
      {Nome}VectorProjeto RealCó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):

      CategoriaTabelas
      Coregerenciador_dados, gerenciador_metadados, gerenciador_projeto_credentials
      Google Drivegerenciamento_googledrive
      OCRclio_ocr, clio_ocr_vector, clio_ocr_segments, clio_ocr_clustering_iterations, clio_ocr_cluster_rejections
      Entidadesclio_entidades, clio_entidades_vector
      Publicaçãopublicacao_tainacan, publicacao_atom
      Sistemasys_equipe, sys_arquivos_temporarios

      Query 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ências
      • node/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"
  24. 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 of GerenciadorDriveGoogle.py (CLI script for Linux)
    • Root Cause: fregeRAG.py contains os.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.js lines 21-23
  25. Path Duplication in Google Drive Structure Read:

    • Symptom: "Erro ao carregar estrutura de arquivos" on /armazenamento page, PM2 logs show ENOENT file not found
    • Cause: After fixing the Python script location, the readDriveStructure function 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 readDriveStructure function:
      // ✅ 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.js lines 247-249

🔄 Tainacan Integration Patterns

Taxonomy Field Mapping

When syncing taxonomy fields between the project and Tainacan:

  1. 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
    }
  2. 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
    }
  3. 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

  1. Always validate before sync:

    const validation = validateForSync(editableMetadata.value)
    if (!validation.valid) {
    showToast('warning', 'Atenção', `Campos obrigatórios: ${validation.missing.join(', ')}`)
    return
    }
  2. Use the correct endpoint for each operation:

    OperationEndpointMethod
    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
  3. 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 FrontendCampo BackendDescrição
nuvem_pasta_raiz_idstorage_root_idID da pasta no Google Drive
nuvem_pasta_raiz_nomestorage_root_nameNome 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étodoRotaDescrição
GET/api/google-drive/structure/:emailRetorna estrutura de pastas do Drive
PUT/api/projeto/:idAtualiza 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étodoRotaDescrição
GET/api/projeto/:id/equipeLista membros da equipe do projeto
POST/api/projeto/:id/equipeAdiciona membro à equipe (endpoint atual; substitui o antigo /equipe/convidar)
GET/api/projeto/convites/pendentesLista convites pendentes do usuário autenticado
POST/api/projeto/convites/:conviteId/aceitarAceita convite pendente
POST/api/projeto/convites/:conviteId/recusarRecusa convite pendente
DELETE/api/projeto/:id/equipe/:usuarioIdRemove membro da equipe
PATCH/api/projeto/:id/equipe/:membroIdAtualiza 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

RoleLeituraEscritaExclusãoAdmin
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

ColunaTítuloConteúdo
1Atividade Recente / StatsEstatísticas e atividades recentes
2Minha ContaDados do usuário logado e assinatura
3Meu ProjetoProjeto 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árioBadge ExibidoBotão do Footer
Proprietário⭐ Proprietário (badge warning)"Configurar Projeto" → /projeto
ConvidadoAlert 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

CategoriaEndpointDescrição
IDPGET /api/stats/idp/ocr-successTaxa de sucesso do OCR por coleção
IDPGET /api/stats/idp/ner-correctionTaxa de correção humana em entidades (NER)
IDPGET /api/stats/idp/entities-volumeVolume de entidades extraídas por tipo
IDPGET /api/stats/idp/top-entitiesTop entidades mais citadas
SIGADGET /api/stats/sigad/volumetriaVolumetria por fundo/coleção
SIGADGET /api/stats/sigad/metadata-qualityCompleteza de metadados (e-ARQ Brasil)
SIGADGET /api/stats/sigad/document-typesDistribuição por gênero documental
SIGADGET /api/stats/sigad/tainacan-syncStatus de sincronização Tainacan
StorageGET /api/stats/storage/by-formatArmazenamento por formato MIME
StorageGET /api/stats/storage/obsolete-formatsFormatos em risco de obsolescência
StorageGET /api/stats/storage/duplicatesDetecção de arquivos duplicados
DashboardGET /api/stats/dashboardVisão consolidada de todas as métricas
DashboardPOST /api/stats/snapshotGera snapshot diário (manual)
DashboardGET /api/stats/historyHistórico de métricas
DashboardGET /api/stats/evolutionEvoluçã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étodoRotaDescrição
POST/api/drive-webhook/callbackRecebe notificações do Google (sync/changes)
POST/api/drive-webhook/registerRegistra novo canal de notificação
POST/api/drive-webhook/renewRenova canais próximos de expirar
DELETE/api/drive-webhook/stopPara de monitorar (cancela canal)
GET/api/drive-webhook/status/:emailStatus 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

  1. Google Drive detecta mudança (arquivo criado, movido, excluído, renomeado)
  2. Envia POST para callback URL com headers:
    • X-Goog-Channel-ID: ID do canal
    • X-Goog-Resource-State: sync (inicial) ou change
    • X-Goog-Resource-ID: ID do recurso monitorado
  3. 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'
    });
  4. Para cada arquivo alterado:
    • trashed: true → Marca removed = 1 na 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:

  1. Verifica se o backend está rodando
  2. 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
  3. 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

  1. URL HTTPS válida: O callback deve ser acessível publicamente via HTTPS

    • URL configurada: https://srv1.arboreolab.com.br/api/drive-webhook/callback
  2. Domínio verificado no Google Cloud Console:

    • Acessar: Console → APIs & Services → Domain verification
    • Adicionar: srv1.arboreolab.com.br
  3. Drive API habilitada com escopos corretos:

    • https://www.googleapis.com/auth/drive
    • https://www.googleapis.com/auth/drive.metadata.readonly

Troubleshooting

ProblemaCausaSolução
Callback não recebe notificaçõesDomínio não verificadoVerificar domínio no GCP Console
Token expiradorefresh_token inválidoRe-autenticar usuário via OAuth
Canal expira em 7 diasLimitação do GoogleCRON renova automaticamente
Erro 401 no callbackToken OAuth expiradodriveWebhook.js faz refresh automático
Arquivo não atualiza na tabelachanges.list() falhaVerificar 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étodoQuando UsarEndpoint/Script
Webhook (automático)Atualizações em tempo realPOST /callback (recebe do Google)
Map Folder (manual)Sincronização inicial completaPython MapeadorDriveGoogle.py
Sync StatusVerificar inconsistênciasGET /api/gerenciador-dados/sync-status
Remove ObsoleteLimpar arquivos movidosDELETE /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étodoRotaDescrição
GET/api/google-drive/collectionsLista coleções disponíveis para upload
POST/api/google-drive/prepare-user-uploadPrepara estrutura de pastas (cria se necessário)
POST/api/google-drive/ensure-folderGarante existência de subpasta (verifica antes de criar)
POST/api/google-drive/uploadFaz 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 destino
  • userEmail: Email do usuário que está fazendo upload
  • targetSubfolder: "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

ProblemaCausaSolução
Estrutura de pastas não preservada (dialog)webkitRelativePath não usadoVerificar handleTriageFileSelect usa webkitRelativePath
Estrutura de pastas não preservada (drag)relativePath não propagadoVerificar traverseFileTree retorna relativePath
Pasta duplicada no DriveScript não verifica existênciaUsar endpoint /ensure-folder em vez de criar diretamente
Erro "Duplicate entry" no webhookINSERT sem verificaçãoWebhook deve usar INSERT ... ON DUPLICATE KEY UPDATE
Upload falha para não-adminPermissões insuficientesVerificar 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:

  1. Abrir DevTools (F12)
  2. Aba Console
  3. 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étodoRotaDescrição
POST/api/pdf-treatment/convertConverte uma imagem individual para PDF
POST/api/pdf-treatment/convert-concatenatedConcatena múltiplas imagens em um único PDF
POST/api/pdf-treatment/convert-batchConversão em lote (múltiplos PDFs separados)
GET/api/pdf-treatment/healthStatus do serviço

Convenção de Nomenclatura (e-ARQ Brasil)

O sistema gera nomes de PDF automaticamente seguindo o padrão arquivístico:

TipoPadrãoExemplo
Individualsubpasta_nomeimagem_data.pdfJAPON_VII_BIENAL1964_foto001_20251230.pdf
Concatenadosubpasta_agrupado_data.pdfJAPON_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 XMPDescriçãoOrigem
dc:creatorResponsável pela digitalizaçãoEmail do usuário logado ou editado
dc:descriptionDescrição do documentoGerado automaticamente
dc:formatFormato MIMEapplication/pdf
xmp:CreateDateData de criação/digitalizaçãoEXIF da imagem ou data atual
xmp:ModifyDateData de modificaçãoData da conversão
xmp:CreatorToolSoftware de criaçãoClio SIGAD - Digitalização ({equipamento})
pdf:ProducerProdutor do PDFClio SIGAD - ArboreoLab
pdf:KeywordsPalavras-chavedigitalizaçã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

ProblemaCausaSolução
XMP não adicionadopikepdf não instaladopip install pikepdf
Erro "Cannot read properties of undefined"generatePdfFileName retorna string, não objetoVerificar uso direto da string
PDF salvo na raiz do DrivefilePath chegando como undefinedVerificar propagação de path na árvore
Imagem não convertidaFormato não suportadoVerificar se é JPG/PNG/TIFF
DPI baixo no PDFImagem original com DPI < 300Sistema 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

PapelNívelDescriçãoPermissões
owner100Proprietário do projetoTudo, incluindo excluir projeto e transferir propriedade
admin90AdministradorGerenciar equipe, alterar configurações, excluir dados
gestor50Gestor de conteúdoEditar, aprovar, publicar documentos
usuario10Usuário colaboradorCriar, editar próprios documentos
visitante0Acesso somente leituraApenas 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çãoNível MínimoUso
requireOwner()100Excluir projeto, transferir propriedade
requireAdmin()90Gerenciar equipe, configurações
requireManage()50Editar qualquer documento, aprovar
requireWrite()10Criar/editar próprios documentos
requireRead()0Visualizar (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

ProblemaCausaSolução
403 em todas as rotasMiddleware sem parêntesesAlterar requireAdmin para requireAdmin()
req.userPermissions undefinedMiddleware não executouVerificar ordem: verifyJwtselectDatabaserequireX()
Usuário com role erradoCache desatualizadoForçar refresh do projetoStore
Owner aparece como adminQuery não verifica arb_projetos.usuario_idUsar fetchUserRole() corrigido
Erro "SELECT denied for table"Permissões MySQLGRANT 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'
});

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_planos com 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


🚀 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)

TabelaFunção
worker_registryCadastro de workers (mac-vision-01, etc)
job_queueFila de processamento com status

Endpoints de Produção OCR Implementados

EndpointMétodoDescrição
/api/workersGETLista workers registrados e status
/api/workers/jobs/queueGETLista fila de jobs
/api/workers/jobsPOSTCria novo job na fila
/api/workers/jobs/:id/dispatchPOSTDespacha job para worker
/api/workers/jobs/:idDELETERemove job da fila
/api/workers/jobs/cleanup-stalePOSTMarca jobs antigos como failed
/api/gerenciador-dados/ocr/production-statusGETStatus de OCR por coleção
/api/gerenciador-dados/ocr/pending-filesGETArquivos pendentes de OCR

Estado dos Dados (GeopoliticasVector)

MétricaValorObservação
Total em gerenciador_dados20.072Documentos catalogados
Com url_jpg (imagens)18.242Imagens brutas
Com url_pdf (PDFs)1.691PDFs tratados
Registros em clio_ocr16.117OCRs processados
OCRs com conteúdo (qtde_caracteres > 100)0⚠️ Todos vazios
Pendentes (status_ocr NULL)3.453Precisam 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():

TipoRegexExemploFormataçã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}$1962texto simples
TECNICALista de palavras-chaveLitografía*itálico*
NUMERO_CATALOGO^\d{1,4}\.\s+\S33. Orbita- lista
TIRAGEM^\d+/\d+$ ou A.P.1/50texto simples
COLECAO^(Coleção|Colección|...)Coleção Particulartexto simples
ASSINATURA_OBRA^(Ass\.|Assinado|Signed)Ass. CIDtexto 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-files para usar gerenciador_dados.status_ocr
  • Adicionar filtro por coleção via colecao (não apenas breadcrumbs)
  • Retornar db_id junto com file_id para atualização posterior

1.2. Implementar Callback de OCR Completo

  • Endpoint POST /api/workers/jobs/:id/callback receber resultado do Mac
  • Atualizar clio_ocr com conteúdo extraído
  • Atualizar gerenciador_dados.status_ocr para "CONCLUÍDO"
  • Gravar qtde_caracteres corretamente

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 /health do worker
  • Atualizar worker_registry.status automaticamente
  • 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

DataAlteração
2025-12-31Implementados endpoints de produção OCR
2025-12-31Adicionado cleanup de jobs stale
2025-12-31Mapeamento de critérios de arquivos pendentes
2026-01-02Documentação de estágio atual e propostas