From ccbba312bbe80e67d407c9bbcdaea5276a6a7fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 9 Mar 2026 17:08:41 -0300 Subject: [PATCH] feat: implement persistent notification system - Added notifications table with auto-migration on startup. - Created backend endpoints for fetching and managing notifications. - Implemented interactive notification tray in the header with unread badges. - Added automated triggers for organization creation and user registration completion. --- backend/index.js | 79 ++++++++++++++++++++++++++++++++++++++++ components/Layout.tsx | 81 +++++++++++++++++++++++++++++++++++++++-- services/dataService.ts | 39 ++++++++++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) diff --git a/backend/index.js b/backend/index.js index 0593763..9ae4887 100644 --- a/backend/index.js +++ b/backend/index.js @@ -266,6 +266,22 @@ apiRouter.post('/auth/reset-password', async (req, res) => { if (name) { // If a name is provided (like in the initial admin setup flow), update it along with the password await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]); + + // Notify managers/admins of the same tenant + const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]); + if (u.length > 0) { + const user = u[0]; + const [notifiable] = await pool.query( + "SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?", + [user.tenant_id, user.id] + ); + for (const n of notifiable) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`] + ); + } + } } else { // Standard password reset, just update the hash await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]); @@ -457,6 +473,43 @@ apiRouter.post('/users/:id/avatar', upload.single('avatar'), async (req, res) => }); +// --- Notifications Routes --- +apiRouter.get('/notifications', async (req, res) => { + try { + const [rows] = await pool.query( + 'SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50', + [req.user.id] + ); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/notifications/:id', async (req, res) => { + try { + await pool.query( + 'UPDATE notifications SET is_read = true WHERE id = ? AND user_id = ?', + [req.params.id, req.user.id] + ); + res.json({ message: 'Notification marked as read' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/notifications/read-all', async (req, res) => { + try { + await pool.query( + 'UPDATE notifications SET is_read = true WHERE user_id = ?', + [req.user.id] + ); + res.json({ message: 'All notifications marked as read' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // --- Global Search --- apiRouter.get('/search', async (req, res) => { const { q } = req.query; @@ -705,6 +758,16 @@ apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => { await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [admin_email, token]); const setupLink = `${getBaseUrl(req)}/#/setup-account?token=${token}`; + + // Add Notification for Super Admins + const [superAdmins] = await connection.query("SELECT id FROM users WHERE role = 'super_admin'"); + for (const sa of superAdmins) { + await connection.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), sa.id, 'success', 'Nova Organização', `A organização ${name} foi criada.`, '/super-admin'] + ); + } + await transporter.sendMail({ from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, to: admin_email, @@ -793,6 +856,22 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + await connection.query(` + CREATE TABLE IF NOT EXISTS notifications ( + id varchar(36) NOT NULL, + user_id varchar(36) NOT NULL, + type enum('success', 'info', 'warning', 'error') DEFAULT 'info', + title varchar(255) NOT NULL, + message text NOT NULL, + link varchar(255) DEFAULT NULL, + is_read boolean DEFAULT false, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY user_id (user_id), + KEY created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + // Add slug column if it doesn't exist try { await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL'); diff --git a/components/Layout.tsx b/components/Layout.tsx index 5d9b7e2..74305a2 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -4,7 +4,7 @@ import { LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut, Hexagon, Settings, Building2, Sun, Moon, Loader2 } from 'lucide-react'; -import { getAttendances, getUsers, getUserById, logout, searchGlobal } from '../services/dataService'; +import { getAttendances, getUsers, getUserById, logout, searchGlobal, getNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '../services/dataService'; import { User } from '../types'; const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => ( @@ -36,6 +36,16 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => const [isSearching, setIsSearching] = useState(false); const [showSearchResults, setShowSearchResults] = useState(false); + // Notifications State + const [notifications, setNotifications] = useState([]); + const [showNotifications, setShowNotifications] = useState(false); + const unreadCount = notifications.filter(n => !n.is_read).length; + + const loadNotifications = async () => { + const data = await getNotifications(); + setNotifications(data); + }; + useEffect(() => { const delayDebounceFn = setTimeout(async () => { if (searchQuery.length >= 2) { @@ -79,6 +89,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => } }; fetchCurrentUser(); + loadNotifications(); + const interval = setInterval(loadNotifications, 60000); + return () => clearInterval(interval); }, [navigate]); const handleLogout = () => { @@ -364,11 +377,73 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {/* Notifications */}
- + + {showNotifications && ( +
+
+

Notificações

+ {unreadCount > 0 && ( + + )} +
+
+ {notifications.length > 0 ? ( + notifications.map(n => ( + + )) + ) : ( +
+ Nenhuma notificação por enquanto. +
+ )} +
+
+ )}
+ + {/* Close notifications when clicking outside */} + {showNotifications &&
setShowNotifications(false)} />}
diff --git a/services/dataService.ts b/services/dataService.ts index fe32a9f..382e728 100644 --- a/services/dataService.ts +++ b/services/dataService.ts @@ -17,6 +17,45 @@ const getHeaders = () => { }; }; +export const getNotifications = async (): Promise => { + try { + const response = await fetch(`${API_URL}/notifications`, { + headers: getHeaders() + }); + if (!response.ok) throw new Error('Failed to fetch notifications'); + return await response.json(); + } catch (error) { + console.error("API Error (getNotifications):", error); + return []; + } +}; + +export const markNotificationAsRead = async (id: string): Promise => { + try { + const response = await fetch(`${API_URL}/notifications/${id}`, { + method: 'PUT', + headers: getHeaders() + }); + return response.ok; + } catch (error) { + console.error("API Error (markNotificationAsRead):", error); + return false; + } +}; + +export const markAllNotificationsAsRead = async (): Promise => { + try { + const response = await fetch(`${API_URL}/notifications/read-all`, { + method: 'PUT', + headers: getHeaders() + }); + return response.ok; + } catch (error) { + console.error("API Error (markAllNotificationsAsRead):", error); + return false; + } +}; + export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => { try { const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {