feat: implement persistent notification system
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m38s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m38s
- 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.
This commit is contained in:
@@ -266,6 +266,22 @@ apiRouter.post('/auth/reset-password', async (req, res) => {
|
|||||||
if (name) {
|
if (name) {
|
||||||
// If a name is provided (like in the initial admin setup flow), update it along with the password
|
// 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]);
|
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 {
|
} else {
|
||||||
// Standard password reset, just update the hash
|
// Standard password reset, just update the hash
|
||||||
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
|
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 ---
|
// --- Global Search ---
|
||||||
apiRouter.get('/search', async (req, res) => {
|
apiRouter.get('/search', async (req, res) => {
|
||||||
const { q } = req.query;
|
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]);
|
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}`;
|
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({
|
await transporter.sendMail({
|
||||||
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
||||||
to: admin_email,
|
to: admin_email,
|
||||||
@@ -793,6 +856,22 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
// Add slug column if it doesn't exist
|
||||||
try {
|
try {
|
||||||
await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL');
|
await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
||||||
Hexagon, Settings, Building2, Sun, Moon, Loader2
|
Hexagon, Settings, Building2, Sun, Moon, Loader2
|
||||||
} from 'lucide-react';
|
} 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';
|
import { User } from '../types';
|
||||||
|
|
||||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
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 [isSearching, setIsSearching] = useState(false);
|
||||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||||
|
|
||||||
|
// Notifications State
|
||||||
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
const data = await getNotifications();
|
||||||
|
setNotifications(data);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delayDebounceFn = setTimeout(async () => {
|
const delayDebounceFn = setTimeout(async () => {
|
||||||
if (searchQuery.length >= 2) {
|
if (searchQuery.length >= 2) {
|
||||||
@@ -79,6 +89,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchCurrentUser();
|
fetchCurrentUser();
|
||||||
|
loadNotifications();
|
||||||
|
const interval = setInterval(loadNotifications, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -364,11 +377,73 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors">
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors"
|
||||||
|
>
|
||||||
<Bell size={20} />
|
<Bell size={20} />
|
||||||
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<div className="p-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center">
|
||||||
|
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await markAllNotificationsAsRead();
|
||||||
|
loadNotifications();
|
||||||
|
}}
|
||||||
|
className="text-xs text-brand-yellow hover:underline"
|
||||||
|
>
|
||||||
|
Marcar todas como lidas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
notifications.map(n => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!n.is_read) await markNotificationAsRead(n.id);
|
||||||
|
if (n.link) navigate(n.link);
|
||||||
|
setShowNotifications(false);
|
||||||
|
loadNotifications();
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||||
|
n.type === 'success' ? 'text-green-500' :
|
||||||
|
n.type === 'warning' ? 'text-orange-500' :
|
||||||
|
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
|
||||||
|
}`}>
|
||||||
|
{n.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
||||||
|
{new Date(n.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||||
|
Nenhuma notificação por enquanto.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Close notifications when clicking outside */}
|
||||||
|
{showNotifications && <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,45 @@ const getHeaders = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNotifications = async (): Promise<any[]> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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[] }> => {
|
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user