Extract backend runtime configuration

This commit is contained in:
Cauê Faleiros
2026-05-29 10:22:41 -03:00
parent aa59e642af
commit c512809a38
7 changed files with 95 additions and 153 deletions

View File

@@ -26,7 +26,9 @@ RUN npm ci --omit=dev
# Copy backend source directly into root
COPY backend/index.js ./index.js
COPY backend/db.js ./db.js
COPY backend/config ./config
COPY backend/policies ./policies
COPY backend/services ./services
COPY backend/utils ./utils
# Copy built frontend

View File

@@ -1,88 +0,0 @@
# Fasto Project Documentation
## Overview
Fasto is a commercial team management system built with React (Vite) on the frontend and Node.js (Express) on the backend. It uses a MySQL database. It features a complete multi-tenant architecture designed to securely host multiple client organizations within a single deployment.
## 🚀 Recent Major Changes (March 2026)
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**:
- **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another.
- **Advanced 2-Token Authentication (Rolling Sessions):**
- Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- Built an Axios-like `apiFetch` interceptor on the frontend that automatically catches 401 Unauthorized errors, fetches a new Access Token in the background, extends the Refresh Token by another 30 days (Sliding Expiration), and retries the original request without logging the user out.
- Full remote revocation capability (Logout drops the token from the DB immediately).
- **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords.
- **Role-Based Access Control (RBAC) Simplification:**
- Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Admin:** Full control over members, teams, funnels, and origins within their specific organization.
- **Manager:** Mid-level control. Can edit basic info of users in their specific team, but cannot change user roles or re-assign users to different teams (only Admins can).
- **Agent:** Restricted access. Can only view their own performance metrics and historical attendances.
- **Dynamic Funnel & Origin Managers:**
- Funnel stages and Lead Origins are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnel/origin groups via relational tables (`funnels`, `funnel_stages`, `origin_groups`, `origin_items`).
- Admins can customize the exact Tailwind color class (e.g., "bg-green-100") for each stage and origin via visual UI pickers.
- Admins assign specific Teams to specific Funnels/Origin Groups.
- The Dashboard pie charts and data tables strictly filter and color-code data based on the active team's configuration. Deleted data falls back to an "Outros" category to prevent chart breakage.
- **n8n / External API Webhooks (Completed):**
- Super Admins can generate persistent `api_keys` for specific tenants.
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
- **Real-Time Notification System:**
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
- **Enhanced UI/UX:**
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`).
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
## 📌 Roadmap / To-Do
- [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
- **Database**: MySQL 8.0 (Schema: `fasto_db` or `agenciac_comia` depending on `.env`).
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
## 📋 Prerequisites
- Docker & Docker Compose
- Node.js (for local development outside Docker)
## ⚙️ Setup & Running
### 1. Environment Variables
Copy `.env.example` to `.env` and adjust values:
```bash
cp .env.example .env
```
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
### 2. Database
The project expects a MySQL database. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `refresh_tokens`, `api_keys`, `origin_groups`, etc.).
### 3. Running Locally (Docker Compose)
To start the application and database locally:
```bash
docker-compose -f docker-compose.local.yml up -d --build
```
- **App**: http://localhost:3001
- **Database**: Port 3306
### 4. Gitea Runner
The `docker-compose.yml` includes a service for a Gitea Runner (`fasto-runner`).
- Persistent data is in `./fasto_runner/data`.
## 🔄 CI/CD Pipeline
The project uses Gitea Actions defined in `.gitea/workflows/build-deploy.yaml`.
- **Triggers**: Push to `main` or `master`.
- **Steps**:
1. Checkout code.
2. Build Docker image.
3. Push to `gitea.blyzer.com.br`.
4. Trigger Portainer webhook.
## 💻 Development
The Dockerfile uses a unified root structure. Both the frontend build and the backend Node.js server are hosted from the same container image.

13
backend/config/cors.js Normal file
View File

@@ -0,0 +1,13 @@
const cors = require('cors');
const createCorsMiddleware = ({ allowedOrigins, isProduction }) => cors({
origin: (origin, callback) => {
if (!origin || !isProduction || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Origem não permitida pelo CORS.'));
},
credentials: true
});
module.exports = { createCorsMiddleware };

50
backend/config/runtime.js Normal file
View File

@@ -0,0 +1,50 @@
const { stripEnvQuotes } = require('../utils/security');
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 3001;
const jwtSecret = process.env.JWT_SECRET || (isProduction ? null : 'fasto_dev_secret_change_me');
if (!jwtSecret) {
throw new Error('JWT_SECRET is required in production.');
}
const allowedOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
const smtp = {
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
port: parseInt(process.env.SMTP_PORT, 10) || 587,
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
debug: process.env.SMTP_DEBUG === 'true',
};
const getBaseUrl = (req) => {
if (process.env.APP_URL) return process.env.APP_URL;
const host = req ? (req.get('host') || 'localhost:3001') : 'localhost:3001';
const protocol = (req && (req.protocol === 'https' || req.get('x-forwarded-proto') === 'https')) ? 'https' : 'http';
if (isProduction && !host.includes('localhost')) {
return `https://${host}`;
}
return `${protocol}://${host}`;
};
const getStartupBaseUrl = () => {
if (process.env.APP_URL) return process.env.APP_URL;
if (isProduction) return 'https://fasto.blyzer.com.br';
return 'http://localhost:3001';
};
module.exports = {
allowedOrigins,
getBaseUrl,
getStartupBaseUrl,
isProduction,
jwtSecret,
port,
smtp,
};

View File

@@ -1,16 +1,17 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const pool = require('./db');
const { stripEnvQuotes, hashSecret, maskSecret } = require('./utils/security');
const { hashSecret, maskSecret } = require('./utils/security');
const transporter = require('./services/mailer');
const { createCorsMiddleware } = require('./config/cors');
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
const {
canReadUser,
canUpdateUser,
@@ -21,69 +22,9 @@ const {
} = require('./policies/accessPolicy');
const app = express();
const PORT = process.env.PORT || 3001;
const isProduction = process.env.NODE_ENV === 'production';
const JWT_SECRET = process.env.JWT_SECRET || (isProduction ? null : 'fasto_dev_secret_change_me');
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is required in production.');
}
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false, // false para 587 (STARTTLS)
auth: {
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
},
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: process.env.SMTP_DEBUG === 'true',
logger: process.env.SMTP_DEBUG === 'true'
});
// Helper para obter a URL base
const getBaseUrl = (req) => {
// Use explicit environment variable if set
if (process.env.APP_URL) return process.env.APP_URL;
// Otherwise, attempt to construct it from the request object dynamically
const host = req ? (req.get('host') || 'localhost:3001') : 'localhost:3001';
const protocol = (req && (req.protocol === 'https' || req.get('x-forwarded-proto') === 'https')) ? 'https' : 'http';
// Se estivermos em produção e o host não for localhost, force HTTPS
if (process.env.NODE_ENV === 'production' && !host.includes('localhost')) {
return `https://${host}`;
}
return `${protocol}://${host}`;
};
// Quando não temos a request (ex: startup), usamos uma versão simplificada
const getStartupBaseUrl = () => {
if (process.env.APP_URL) return process.env.APP_URL;
if (process.env.NODE_ENV === 'production') return 'https://fasto.blyzer.com.br';
return 'http://localhost:3001';
};
const allowedOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin || !isProduction || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Origem não permitida pelo CORS.'));
},
credentials: true
}));
app.use(createCorsMiddleware({ allowedOrigins, isProduction }));
app.use(express.json());
// Logger de Requisições

View File

@@ -0,0 +1,20 @@
const nodemailer = require('nodemailer');
const { smtp } = require('../config/runtime');
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: false,
auth: {
user: smtp.user,
pass: smtp.pass,
},
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: smtp.debug,
logger: smtp.debug
});
module.exports = transporter;

View File

@@ -235,7 +235,11 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{!isSuperAdmin && (
<>
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
{currentUser.role === 'agent' && (
<SidebarItem to={`/users/${currentUser.slug || currentUser.id}`} icon={MessageSquare} label="Seus Atendimentos" collapsed={isSidebarCollapsed} />
)}
{currentUser.role !== 'agent' && (
<>
<SidebarItem to="/ranking" icon={Trophy} label="Ranking de Vendedores" collapsed={isSidebarCollapsed} />