feat(notifications): Add notification system with service layer and UI integration

This commit is contained in:
debudebuye 2026-02-24 21:01:08 +03:00
parent d251958a9b
commit 7b0b5099fe
9 changed files with 524 additions and 92 deletions

View File

@ -24,6 +24,7 @@ import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
import AnalyticsStoragePage from "@/pages/admin/analytics/storage" import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
import AnalyticsApiPage from "@/pages/admin/analytics/api" import AnalyticsApiPage from "@/pages/admin/analytics/api"
import HealthPage from "@/pages/admin/health" import HealthPage from "@/pages/admin/health"
import NotificationsPage from "@/pages/notifications"
function App() { function App() {
return ( return (
@ -62,6 +63,7 @@ function App() {
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} /> <Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} /> <Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
<Route path="admin/health" element={<HealthPage />} /> <Route path="admin/health" element={<HealthPage />} />
<Route path="notifications" element={<NotificationsPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} /> <Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes> </Routes>

View File

@ -12,15 +12,23 @@ import {
Activity, Activity,
Heart, Heart,
Search, Search,
Mail,
Bell, Bell,
LogOut, LogOut,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { authService } from "@/services" import { authService } from "@/services"
import { toast } from "sonner"
interface User { interface User {
email: string email: string
@ -46,6 +54,7 @@ export function AppShell() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [searchQuery, setSearchQuery] = useState("")
useEffect(() => { useEffect(() => {
const userStr = localStorage.getItem('user') const userStr = localStorage.getItem('user')
@ -75,6 +84,28 @@ export function AppShell() {
navigate('/login', { replace: true }) navigate('/login', { replace: true })
} }
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
const currentPath = location.pathname
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`)
toast.success(`Searching for: ${searchQuery}`)
}
}
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value)
}
const handleNotificationClick = () => {
console.log('Notification button clicked')
navigate('/notifications')
}
const handleProfileClick = () => {
navigate('/admin/settings')
}
const getUserInitials = () => { const getUserInitials = () => {
if (user?.firstName && user?.lastName) { if (user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase() return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
@ -155,24 +186,50 @@ export function AppShell() {
<header className="h-16 border-b bg-background flex items-center justify-between px-6"> <header className="h-16 border-b bg-background flex items-center justify-between px-6">
<h1 className="text-2xl font-bold">{getPageTitle()}</h1> <h1 className="text-2xl font-bold">{getPageTitle()}</h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative"> <form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="Quick Search..." placeholder="Quick Search..."
className="pl-10 w-64" className="pl-10 w-64"
value={searchQuery}
onChange={handleSearchChange}
/> />
</div> </form>
<Button variant="ghost" size="icon" className="relative"> <Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
<Mail className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</Button>
<Button variant="ghost" size="icon" className="relative">
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" /> <span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Avatar> <Avatar>
<AvatarFallback>{getUserInitials()}</AvatarFallback> <AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar> </Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{getUserDisplayName()}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleProfileClick}>
<Settings className="w-4 h-4 mr-2" />
Profile Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/notifications')}>
<Bell className="w-4 h-4 mr-2" />
Notifications
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>

View File

@ -33,7 +33,40 @@ export default function DashboardPage() {
}) })
const handleExport = () => { const handleExport = () => {
toast.success("Exporting dashboard data...") try {
// Create CSV content from current dashboard data
const csvContent = [
['Metric', 'Value'],
['Total Users', overview?.users?.total || 0],
['Active Users', overview?.users?.active || 0],
['Inactive Users', overview?.users?.inactive || 0],
['Total Invoices', overview?.invoices?.total || 0],
['Total Revenue', overview?.revenue?.total || 0],
['Storage Used', overview?.storage?.totalSize || 0],
['Total Documents', overview?.storage?.documents || 0],
['Error Rate', errorRate?.errorRate || 0],
['Total Errors', errorRate?.errors || 0],
['Export Date', new Date().toISOString()],
]
.map(row => row.join(','))
.join('\n')
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `admin-dashboard-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Dashboard data exported successfully!")
} catch (error) {
toast.error("Failed to export data. Please try again.")
console.error('Export error:', error)
}
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {

View File

@ -17,7 +17,36 @@ export default function DashboardPage() {
}) })
const handleExport = () => { const handleExport = () => {
toast.success("Exporting your data...") try {
// Create CSV content from current stats
const csvContent = [
['Metric', 'Value'],
['Total Invoices', stats?.totalInvoices || 0],
['Pending Invoices', stats?.pendingInvoices || 0],
['Total Transactions', stats?.totalTransactions || 0],
['Total Revenue', stats?.totalRevenue || 0],
['Growth Percentage', stats?.growthPercentage || 0],
['Export Date', new Date().toISOString()],
]
.map(row => row.join(','))
.join('\n')
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `dashboard-export-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Data exported successfully!")
} catch (error) {
toast.error("Failed to export data. Please try again.")
console.error('Export error:', error)
}
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {

View File

@ -1,3 +1,5 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -10,18 +12,162 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Search, Download, Eye, MoreVertical, Bell } from "lucide-react" import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react"
import { notificationService } from "@/services/notification.service"
import { toast } from "sonner"
export default function NotificationsPage() { export default function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const { data: notifications, isLoading, refetch } = useQuery({
queryKey: ['notifications'],
queryFn: () => notificationService.getNotifications(),
})
const { data: unreadCount } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationService.getUnreadCount(),
})
// Client-side filtering
const filteredNotifications = useMemo(() => {
if (!notifications) return []
return notifications.filter((notification) => {
// Type filter
if (typeFilter && notification.type !== typeFilter) return false
// Status filter
if (statusFilter === 'read' && !notification.isRead) return false
if (statusFilter === 'unread' && notification.isRead) return false
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
notification.title.toLowerCase().includes(query) ||
notification.message.toLowerCase().includes(query) ||
notification.recipient.toLowerCase().includes(query)
)
}
return true
})
}, [notifications, typeFilter, statusFilter, searchQuery])
const handleExport = () => {
try {
if (!filteredNotifications || filteredNotifications.length === 0) {
toast.error("No notifications to export")
return
}
const csvData = [
['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'],
...filteredNotifications.map(n => [
n.id,
n.title,
n.message,
n.type,
n.recipient,
n.isRead ? 'Read' : 'Unread',
new Date(n.createdAt).toLocaleString(),
n.readAt ? new Date(n.readAt).toLocaleString() : '-'
])
]
const csvContent = csvData.map(row =>
row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `notifications-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Notifications exported successfully!")
} catch (error) {
toast.error("Failed to export notifications")
console.error('Export error:', error)
}
}
const handleMarkAsRead = async (id: string) => {
try {
await notificationService.markAsRead(id)
toast.success("Notification marked as read")
refetch()
} catch (error) {
toast.error("Failed to mark notification as read")
console.error('Mark as read error:', error)
}
}
const handleMarkAllAsRead = async () => {
try {
await notificationService.markAllAsRead()
toast.success("All notifications marked as read")
refetch()
} catch (error) {
toast.error("Failed to mark all as read")
console.error('Mark all as read error:', error)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
const formatDateTime = (dateString?: string) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusBadge = (isRead: boolean) => {
return isRead ? 'bg-gray-500' : 'bg-orange-500'
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Notifications</h2> <h2 className="text-3xl font-bold">Notifications</h2>
{unreadCount !== undefined && unreadCount > 0 && (
<p className="text-sm text-muted-foreground mt-1">
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
</p>
)}
</div>
<div className="flex gap-2">
{unreadCount !== undefined && unreadCount > 0 && (
<Button variant="outline" onClick={handleMarkAllAsRead}>
<CheckCheck className="w-4 h-4 mr-2" />
Mark All as Read
</Button>
)}
<Button> <Button>
<Bell className="w-4 h-4 mr-2" /> <Bell className="w-4 h-4 mr-2" />
Create Notification Settings
</Button> </Button>
</div> </div>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@ -33,20 +179,32 @@ export default function NotificationsPage() {
<Input <Input
placeholder="Search notification..." placeholder="Search notification..."
className="pl-10 w-64" className="pl-10 w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<select className="px-3 py-2 border rounded-md text-sm"> <select
<option>All Types</option> className="px-3 py-2 border rounded-md text-sm"
<option>System</option> value={typeFilter}
<option>User</option> onChange={(e) => setTypeFilter(e.target.value)}
<option>Alert</option> >
<option value="">All Types</option>
<option value="system">System</option>
<option value="user">User</option>
<option value="alert">Alert</option>
<option value="invoice">Invoice</option>
<option value="payment">Payment</option>
</select> </select>
<select className="px-3 py-2 border rounded-md text-sm"> <select
<option>All Status</option> className="px-3 py-2 border rounded-md text-sm"
<option>Read</option> value={statusFilter}
<option>Unread</option> onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All Status</option>
<option value="read">Read</option>
<option value="unread">Unread</option>
</select> </select>
<Button variant="outline"> <Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Export Export
</Button> </Button>
@ -54,68 +212,64 @@ export default function NotificationsPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : filteredNotifications && filteredNotifications.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Notification ID</TableHead> <TableHead>Notification ID</TableHead>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>Message</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created Date</TableHead> <TableHead>Created Date</TableHead>
<TableHead>Sent Date</TableHead> <TableHead>Read Date</TableHead>
<TableHead>Action</TableHead> <TableHead>Action</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow> {filteredNotifications.map((notification) => (
<TableCell className="font-medium">NOT001</TableCell> <TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
<TableCell>System Update Available</TableCell> <TableCell className="font-medium">{notification.id}</TableCell>
<TableCell className="font-medium">{notification.title}</TableCell>
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
<TableCell> <TableCell>
<Badge variant="outline">System</Badge> <Badge variant="outline" className="capitalize">{notification.type}</Badge>
</TableCell> </TableCell>
<TableCell>All Users</TableCell>
<TableCell> <TableCell>
<Badge className="bg-blue-500">Sent</Badge> <Badge className={getStatusBadge(notification.isRead)}>
{notification.isRead ? 'Read' : 'Unread'}
</Badge>
</TableCell> </TableCell>
<TableCell>2024-01-15</TableCell> <TableCell>{formatDate(notification.createdAt)}</TableCell>
<TableCell>2024-01-15 10:00</TableCell> <TableCell>{formatDateTime(notification.readAt)}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> {!notification.isRead && (
<Button variant="ghost" size="icon"> <Button
variant="ghost"
size="icon"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
>
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
<Button variant="ghost" size="icon"> )}
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">NOT002</TableCell>
<TableCell>Payment Received</TableCell>
<TableCell>
<Badge variant="outline">User</Badge>
</TableCell>
<TableCell>john@example.com</TableCell>
<TableCell>
<Badge className="bg-green-500">Delivered</Badge>
</TableCell>
<TableCell>2024-01-14</TableCell>
<TableCell>2024-01-14 14:30</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
) : (
<div className="text-center py-8 text-muted-foreground">
{searchQuery || typeFilter || statusFilter
? 'No notifications match your filters'
: 'No notifications found'
}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -102,6 +102,16 @@ class AnalyticsService {
const response = await apiClient.get('/admin/analytics/storage') const response = await apiClient.get('/admin/analytics/storage')
return response.data return response.data
} }
/**
* Export analytics data
*/
async exportData(): Promise<Blob> {
const response = await apiClient.get('/admin/analytics/export', {
responseType: 'blob',
})
return response.data
}
} }
export const analyticsService = new AnalyticsService() export const analyticsService = new AnalyticsService()

View File

@ -49,6 +49,16 @@ class DashboardService {
}) })
return response.data return response.data
} }
/**
* Export user dashboard data
*/
async exportData(): Promise<Blob> {
const response = await apiClient.get('/user/export', {
responseType: 'blob',
})
return response.data
}
} }
export const dashboardService = new DashboardService() export const dashboardService = new DashboardService()

View File

@ -8,6 +8,7 @@ export { announcementService } from './announcement.service'
export { auditService } from './audit.service' export { auditService } from './audit.service'
export { settingsService } from './settings.service' export { settingsService } from './settings.service'
export { dashboardService } from './dashboard.service' export { dashboardService } from './dashboard.service'
export { notificationService } from './notification.service'
// Export types // Export types
export type { LoginRequest, LoginResponse } from './auth.service' export type { LoginRequest, LoginResponse } from './auth.service'
@ -19,3 +20,6 @@ export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } fro
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service' export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service' export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
export type { UserDashboardStats, UserProfile } from './dashboard.service' export type { UserDashboardStats, UserProfile } from './dashboard.service'
export type { Notification } from './notification.service'
export type { NotificationSettings } from './notification.service'
export type { NotificationSettings } from './notification.service'

View File

@ -0,0 +1,133 @@
import apiClient from './api/client'
export interface Notification {
id: string
title: string
message: string
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
recipient: string
status: 'sent' | 'delivered' | 'read' | 'unread'
isRead: boolean
createdAt: string
sentAt?: string
readAt?: string
}
export interface NotificationSettings {
emailNotifications: boolean
pushNotifications: boolean
invoiceReminders: boolean
paymentAlerts: boolean
systemUpdates: boolean
}
class NotificationService {
/**
* Get all notifications for current user
*/
async getNotifications(params?: {
type?: string
status?: string
search?: string
}): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>('/notifications', {
params,
})
return response.data
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
return response.data.count
}
/**
* Mark notification as read
*/
async markAsRead(id: string): Promise<void> {
await apiClient.post(`/notifications/${id}/read`)
}
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.post('/notifications/read-all')
}
/**
* Send notification (ADMIN only)
*/
async sendNotification(data: {
title: string
message: string
type: string
recipient?: string
recipientType?: 'user' | 'all'
}): Promise<Notification> {
const response = await apiClient.post<Notification>('/notifications/send', data)
return response.data
}
/**
* Subscribe to push notifications
*/
async subscribeToPush(subscription: PushSubscription): Promise<void> {
await apiClient.post('/notifications/subscribe', subscription)
}
/**
* Unsubscribe from push notifications
*/
async unsubscribeFromPush(endpoint: string): Promise<void> {
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
}
/**
* Get notification settings
*/
async getSettings(): Promise<NotificationSettings> {
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
return response.data
}
/**
* Update notification settings
*/
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
return response.data
}
/**
* Send invoice reminder
*/
async sendInvoiceReminder(invoiceId: string): Promise<void> {
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`)
}
/**
* Export notifications (creates CSV from current data)
*/
async exportNotifications(notifications: Notification[]): Promise<Blob> {
const csvContent = [
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'],
...notifications.map(n => [
n.id,
n.title,
n.message,
n.type,
n.status,
n.createdAt,
n.readAt || '-'
])
].map(row => row.join(',')).join('\n')
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
}
}
export const notificationService = new NotificationService()