Compare commits

..

16 Commits

Author SHA1 Message Date
Cauê Faleiros
509ed4a0d9 update docker compose again uhuu
All checks were successful
Build and Deploy / build-and-push (push) Successful in 57s
2026-03-25 16:43:07 -03:00
07cb43d0e3 revert b8541c0d24
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
revert update docker compose again
2026-03-25 19:42:03 +00:00
Cauê Faleiros
b8541c0d24 update docker compose again
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:39:21 -03:00
Cauê Faleiros
062864364a update docker compose
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:38:23 -03:00
Cauê Faleiros
d148984028 chore: remove temporary postgres testing service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s
- The portainer webhook testing service fulfilled its purpose and is no longer needed.
2026-03-25 15:03:39 -03:00
Cauê Faleiros
fa683ab28c chore: add dummy postgres service for portainer debugging
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Added a simple postgres service to test if Portainer is successfully pulling and applying stack updates from the repository.
2026-03-25 14:48:24 -03:00
Cauê Faleiros
83e6da2d56 fix: add missing swarm deploy block to mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Docker Swarm (Portainer) ignores 'restart: always' and often requires a 'deploy' block with replica counts to properly schedule a new service.

- Replaced standard docker-compose restart policy with a Swarm-compliant deploy block.
2026-03-25 14:43:03 -03:00
Cauê Faleiros
4dbd7c62cd fix: replace deprecated mysql backup image with modern cron backup container
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m5s
- The 'databack/mysql-backup' image crashes immediately when used in a pure cron context without legacy arguments and is incompatible with MySQL 8 caching_sha2_password.

- Switched to the actively maintained 'fradelg/mysql-cron-backup' image.

- Re-mapped environment variables to match the new image expectations (MYSQL_HOST, CRON_TIME, MAX_BACKUPS).

- Updated volume mapping destination to '/backup' as expected by the new image.
2026-03-25 14:34:36 -03:00
Cauê Faleiros
b5c8e97701 fix: revert backup volume path to exact specification
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
- Updated the host bind mount path to exactly '/opt/backups_db' as explicitly required by the server configuration.
2026-03-25 14:20:05 -03:00
Cauê Faleiros
f65ff97434 fix: change mysql backup volume path to prevent permission denied errors
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m7s
- Updated the host bind mount from the restricted '/root' directory to '/opt/fasto_backups' to ensure the Docker daemon has the necessary permissions to read and write database dumps.
2026-03-25 14:18:16 -03:00
Cauê Faleiros
958a2cdbd9 chore: force recreate database init sql config in docker swarm
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Appended a version suffix to the `init_sql` config name to bypass Docker Swarm's immutable config cache and force the cluster to pick up the latest database schema changes on deployment.
2026-03-25 13:55:31 -03:00
Cauê Faleiros
eb483f903b chore: remove swarm deployment constraint from mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Dropped the 'node.role == manager' deployment constraint to allow the backup container to be scheduled on any available node or to run smoothly in non-swarm Docker Compose environments.
2026-03-25 13:23:34 -03:00
Cauê Faleiros
9ffcfcdcc8 chore: add automated database backup service and tighten backend security
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m56s
- Added `databack/mysql-backup` service to the production docker-compose Swarm stack, scheduling a daily 02:55 AM cron backup of the database with a 3-day local retention policy.

- Fixed a critical race condition in the backend JWT authentication middleware where an invalid token returning 401 could crash the response flow if the route executed before the defensive checks caught it.

- Added strict undefined defensive checks to the `getUserById` endpoint and RBAC middleware to gracefully reject requests that somehow bypass the token parser.

- Updated `GEMINI.md` technical documentation to fully match the real codebase logic.

- Fixed UX rule to prevent `manager` role from seeing Funnels or Origins tabs in the sidebar.

- Blocked `agent` role from modifying their own 'fullName' string in the Profile UI.
2026-03-25 12:40:53 -03:00
Cauê Faleiros
3663d03cb9 refactor(rbac): complete the removal of 'owner' role from backend routes and logic
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m48s
- Cleaned up the requireRole middleware across all Funnel and Origin API routes to strictly allow only 'admin' and 'super_admin' to perform structural changes.

- Updated the tenant creation script to assign the 'admin' role to new signups instead of 'owner'.
2026-03-23 15:40:36 -03:00
Cauê Faleiros
2317c46ac9 fix: change expired JWT response code to 401 to properly trigger frontend interceptor
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m42s
- The backend was returning 403 Forbidden when a token expired, causing the frontend apiFetch interceptor (which listens for 401) to ignore it and crash the session.
2026-03-19 16:31:20 -03:00
Cauê Faleiros
4489f0a74d refactor: completely remove 'owner' role from RBAC system
- The platform now strictly uses 'super_admin', 'admin', 'manager', and 'agent' to simplify permissions and match business requirements.
2026-03-19 15:33:16 -03:00
6 changed files with 237 additions and 97 deletions

160
App.tsx
View File

@@ -1,34 +1,48 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import {
import { Layout } from './components/Layout'; HashRouter as Router,
import { Dashboard } from './pages/Dashboard'; Routes,
import { UserDetail } from './pages/UserDetail'; Route,
import { AttendanceDetail } from './pages/AttendanceDetail'; Navigate,
import { SuperAdmin } from './pages/SuperAdmin'; useLocation,
import { ApiKeys } from './pages/ApiKeys'; } from "react-router-dom";
import { TeamManagement } from './pages/TeamManagement'; import { Layout } from "./components/Layout";
import { Teams } from './pages/Teams'; import { Dashboard } from "./pages/Dashboard";
import { Funnels } from './pages/Funnels'; import { UserDetail } from "./pages/UserDetail";
import { Origins } from './pages/Origins'; import { AttendanceDetail } from "./pages/AttendanceDetail";
import { Login } from './pages/Login'; import { SuperAdmin } from "./pages/SuperAdmin";
import { ForgotPassword } from './pages/ForgotPassword'; import { ApiKeys } from "./pages/ApiKeys";
import { ResetPassword } from './pages/ResetPassword'; import { TeamManagement } from "./pages/TeamManagement";
import { SetupAccount } from './pages/SetupAccount'; import { Teams } from "./pages/Teams";
import { UserProfile } from './pages/UserProfile'; import { Funnels } from "./pages/Funnels";
import { getUserById, logout } from './services/dataService'; import { Origins } from "./pages/Origins";
import { User } from './types'; import { Login } from "./pages/Login";
import { ForgotPassword } from "./pages/ForgotPassword";
import { ResetPassword } from "./pages/ResetPassword";
import { SetupAccount } from "./pages/SetupAccount";
import { UserProfile } from "./pages/UserProfile";
import { getUserById, logout } from "./services/dataService";
import { User } from "./types";
const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({ children, roles }) => { const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
children,
roles,
}) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const storedUserId = localStorage.getItem('ctms_user_id'); const storedUserId = localStorage.getItem("ctms_user_id");
const storedToken = localStorage.getItem('ctms_token'); const storedToken = localStorage.getItem("ctms_token");
if (!storedUserId || !storedToken || storedToken === 'undefined' || storedToken === 'null') { if (
!storedUserId ||
!storedToken ||
storedToken === "undefined" ||
storedToken === "null"
) {
if (storedToken) logout(); // Limpar se for "undefined" string if (storedToken) logout(); // Limpar se for "undefined" string
setLoading(false); setLoading(false);
return; return;
@@ -37,7 +51,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
try { try {
const fetchedUser = await getUserById(storedUserId); const fetchedUser = await getUserById(storedUserId);
if (fetchedUser) { if (fetchedUser) {
if (fetchedUser.status === 'active') { if (fetchedUser.status === "active") {
setUser(fetchedUser); setUser(fetchedUser);
} else { } else {
// User explicitly marked inactive or deleted // User explicitly marked inactive or deleted
@@ -66,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
}, [location.pathname]); }, [location.pathname]);
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">Carregando...</div>; return (
<div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">
Carregando...
</div>
);
} }
if (!user) { if (!user) {
@@ -78,7 +96,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
} }
// Auto-redirect Super Admins away from the standard dashboard to their specific panel // Auto-redirect Super Admins away from the standard dashboard to their specific panel
if (location.pathname === '/' && user.role === 'super_admin') { if (location.pathname === "/" && user.role === "super_admin") {
return <Navigate to="/super-admin" replace />; return <Navigate to="/super-admin" replace />;
} }
@@ -93,16 +111,86 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} /> <Route path="/setup-account" element={<SetupAccount />} />
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} /> <Route
<Route path="/admin/users" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><TeamManagement /></AuthGuard>} /> path="/"
<Route path="/admin/teams" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Teams /></AuthGuard>} /> element={
<Route path="/admin/funnels" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Funnels /></AuthGuard>} /> <AuthGuard>
<Route path="/admin/origins" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Origins /></AuthGuard>} /> <Dashboard />
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} /> </AuthGuard>
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} /> }
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} /> />
<Route path="/super-admin/api-keys" element={<AuthGuard roles={['super_admin']}><ApiKeys /></AuthGuard>} /> <Route
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} /> path="/admin/users"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<TeamManagement />
</AuthGuard>
}
/>
<Route
path="/admin/teams"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<Teams />
</AuthGuard>
}
/>
<Route
path="/admin/funnels"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Funnels />
</AuthGuard>
}
/>
<Route
path="/admin/origins"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Origins />
</AuthGuard>
}
/>
<Route
path="/users/:id"
element={
<AuthGuard>
<UserDetail />
</AuthGuard>
}
/>
<Route
path="/attendances/:id"
element={
<AuthGuard>
<AttendanceDetail />
</AuthGuard>
}
/>
<Route
path="/super-admin"
element={
<AuthGuard roles={["super_admin"]}>
<SuperAdmin />
</AuthGuard>
}
/>
<Route
path="/super-admin/api-keys"
element={
<AuthGuard roles={["super_admin"]}>
<ApiKeys />
</AuthGuard>
}
/>
<Route
path="/profile"
element={
<AuthGuard>
<UserProfile />
</AuthGuard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Router> </Router>

View File

@@ -7,38 +7,44 @@ Fasto is a commercial team management system built with React (Vite) on the fron
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**: 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. - **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.
- **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, while strictly maintaining state isolation through forced React reloads and locking mechanisms. - **Advanced 2-Token Authentication (Rolling Sessions):**
- **Dynamic Funnel Customization:** - Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Funnel stages are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnels via the `funnels` and `funnel_stages` tables. - Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- Managers can assign specific Teams to specific Funnels. - 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.
- The Dashboard, User Detail, and Attendance Detail pages now dynamically map and color-code stages based on the active team's assigned funnel. - Full remote revocation capability (Logout drops the token from the DB immediately).
- **Real-Time Notification System:** - **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.
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback. - **Role-Based Access Control (RBAC) Simplification:**
- Automated Triggers: Super Admins are notified of new organizations; Tenant Admins/Managers are notified of new user setups; Users are notified of team assignment changes. - Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Role-Based Access Control (RBAC):** - **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant). - **Admin:** Full control over members, teams, funnels, and origins within their specific organization.
- **Admin/Manager:** Full control over members and teams 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. - **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:** - **Enhanced UI/UX:**
- Premium "Onyx & Gold" True Black dark mode. - Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`). - Fully collapsible interactive sidebar with memory (`localStorage`).
- Action buttons across all data tables are permanently visible for faster discoverability (removed hover requirements). - All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
- Loading states embedded directly into action buttons to prevent double-submissions.
- **Secure File Uploads:** Profile avatars use `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation.
## 📌 Roadmap / To-Do ## 📌 Roadmap / To-Do
- [ ] **n8n / External API Integration (Priority 1):** - [ ] **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`).
- Create an `api_keys` table to allow generating persistent, secure API keys for each Tenant.
- Build `GET /api/integration/users` so n8n can map its chat agents to Fasto's internal `user_id`s.
- Build `POST /api/integration/attendances` to allow n8n to programmatically create new attendances linked to specific tenants and users.
- [ ] **Sales & Quality Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance is marked as "Won" (Ganhos), receives a critically low quality score, or breaches a specific Response Time SLA.
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel. - [ ] **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. - [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
## 🛠 Architecture ## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React. - **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer. - **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
- **Database**: MySQL 8.0 (Schema: `fasto_db`). - **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. - **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
## 📋 Prerequisites ## 📋 Prerequisites
@@ -55,7 +61,7 @@ cp .env.example .env
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes. *Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
### 2. Database ### 2. Database
The project expects a MySQL database. The `docker-compose.local.yml` initializes it. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `notifications`, `funnels`, `funnel_stages`, and modifying `attendances`). 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) ### 3. Running Locally (Docker Compose)
To start the application and database locally: To start the application and database locally:

View File

@@ -136,14 +136,17 @@ const authenticateToken = async (req, res, next) => {
if (!token) return res.status(401).json({ error: 'Token não fornecido.' }); if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
jwt.verify(token, JWT_SECRET, (err, user) => { try {
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' }); const user = jwt.verify(token, JWT_SECRET);
req.user = user; req.user = user;
next(); next();
}); } catch (err) {
return res.status(401).json({ error: 'Token inválido ou expirado.' });
}
}; };
const requireRole = (roles) => (req, res, next) => { const requireRole = (roles) => (req, res, next) => {
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' }); if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
next(); next();
}; };
@@ -206,8 +209,7 @@ apiRouter.post('/auth/verify', async (req, res) => {
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']); [userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.commit(); await connection.commit();
res.json({ message: 'Sucesso.' }); res.json({ message: 'Sucesso.' });
@@ -458,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]); const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!req.user) return res.status(401).json({ error: 'Não autenticado' }); if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
@@ -541,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
const isSelf = req.user.id === req.params.id; const isSelf = req.user.id === req.params.id;
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role); const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
if (!isSelf && !isManagerOrAdmin) { if (!isSelf && !isManagerOrAdmin) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
@@ -551,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
} }
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role; // Only Admins can change roles and teams. Managers can only edit basic info of their team members.
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id; const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status; const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
const finalEmail = email !== undefined ? email : existing[0].email; const finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
@@ -584,7 +588,7 @@ apiRouter.put('/users/:id', async (req, res) => {
} }
}); });
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -733,7 +737,7 @@ apiRouter.get('/origins', async (req, res) => {
} }
}); });
apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -745,7 +749,7 @@ apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admi
} }
}); });
apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body; const { name, teamIds } = req.body;
try { try {
if (name) { if (name) {
@@ -763,7 +767,7 @@ apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_a
} }
}); });
apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]); await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
@@ -774,7 +778,7 @@ apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'supe
} }
}); });
apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body; const { name, color_class } = req.body;
try { try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
@@ -788,7 +792,7 @@ apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', '
} }
}); });
apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body; const { name, color_class } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
@@ -801,7 +805,7 @@ apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'su
} }
}); });
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
res.json({ message: 'Origin item deleted.' }); res.json({ message: 'Origin item deleted.' });
@@ -862,7 +866,7 @@ apiRouter.get('/funnels', async (req, res) => {
} }
}); });
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -874,7 +878,7 @@ apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admi
} }
}); });
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body; const { name, teamIds } = req.body;
try { try {
if (name) { if (name) {
@@ -892,7 +896,7 @@ apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_a
} }
}); });
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]); await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
@@ -903,7 +907,7 @@ apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'supe
} }
}); });
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const sid = `stage_${crypto.randomUUID().split('-')[0]}`; const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
@@ -917,7 +921,7 @@ apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager',
} }
}); });
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
@@ -933,7 +937,7 @@ apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 's
} }
}); });
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
res.json({ message: 'Stage deleted.' }); res.json({ message: 'Stage deleted.' });
@@ -958,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
membersQ += ' AND tenant_id = ?'; membersQ += ' AND tenant_id = ?';
membersParams.push(req.user.tenant_id); membersParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -976,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
teamsQ += ' AND tenant_id = ?'; teamsQ += ' AND tenant_id = ?';
teamsParams.push(req.user.tenant_id); teamsParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -999,7 +1003,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
attendancesQ += ' AND a.tenant_id = ?'; attendancesQ += ' AND a.tenant_id = ?';
attendancesParams.push(req.user.tenant_id); attendancesParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -1090,7 +1094,7 @@ apiRouter.get('/attendances/:id', async (req, res) => {
}); });
// --- API Key Management Routes --- // --- API Key Management Routes ---
apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const { tenantId } = req.query; const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
@@ -1103,7 +1107,7 @@ apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async
} }
}); });
apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -1123,7 +1127,7 @@ apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' }); if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
@@ -1153,8 +1157,19 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) =>
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => { apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
try { try {
const [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]); const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
res.json(origins.map(o => o.name)); if (groups.length === 0) return res.json([]);
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
const result = groups.map(g => ({
group_name: g.name,
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
}));
res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -1301,7 +1316,7 @@ apiRouter.get('/teams', async (req, res) => {
} }
}); });
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/teams', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description, tenantId } = req.body; const { name, description, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -1317,7 +1332,7 @@ apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (
} }
}); });
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.put('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
@@ -1337,7 +1352,7 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });

View File

@@ -239,8 +239,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<> <>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} /> {currentUser.role !== 'manager' && (
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} /> <>
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
</>
)}
</> </>
)} )}
</> </>

View File

@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
app: app:
@@ -43,12 +43,31 @@ services:
networks: networks:
- fasto-net - fasto-net
backup-mysql:
image: fradelg/mysql-cron-backup
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
MYSQL_HOST: db
MYSQL_USER: root
MYSQL_PASS: ${DB_PASSWORD:-root_password}
CRON_TIME: "55 2 * * *" # Roda todo dia exatamente às 02:55 da manhã
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
volumes:
- /opt/backups_db/fasto:/backup
networks:
- fasto-net
volumes: volumes:
db_data: db_data:
configs: configs:
init_sql: init_sql:
file: ./agenciac_comia.sql file: ./agenciac_comia.sql
name: init_sql_v2
networks: networks:
fasto-net: fasto-net:

View File

@@ -202,9 +202,17 @@ export const UserProfile: React.FC = () => {
id="fullName" id="fullName"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400 transition-all sm:text-sm" disabled={user.role === 'agent'}
className={`block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg sm:text-sm transition-all ${
user.role === 'agent'
? 'bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed'
: 'bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400'
}`}
/> />
</div> </div>
{user.role === 'agent' && (
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate um administrador para alterar seu nome.</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">