diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 00000000..c4eee600 --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,9 @@ +import { NotificationController } from '@/modules/notification/notification.controller'; + +export async function DELETE(req: Request) { + return NotificationController.deleteNotification(req); +} + +export async function PATCH(req: Request) { + return NotificationController.markAsRead(req); +} diff --git a/src/app/api/notifications/mark-all-read/route.ts b/src/app/api/notifications/mark-all-read/route.ts new file mode 100644 index 00000000..7cffa78f --- /dev/null +++ b/src/app/api/notifications/mark-all-read/route.ts @@ -0,0 +1,5 @@ +import { NotificationController } from '@/modules/notification/notification.controller'; + +export async function POST(req: Request) { + return NotificationController.markAllAsRead(req); +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 00000000..f1d1178a --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,13 @@ +import { NotificationController } from '@/modules/notification/notification.controller'; + +export async function GET(req: Request) { + return NotificationController.getNotifications(req); +} + +export async function POST(req: Request) { + return NotificationController.createNotification(req); +} + +export async function DELETE(req: Request) { + return NotificationController.deleteAllRead(req); +} diff --git a/src/app/api/notifications/unread-count/route.ts b/src/app/api/notifications/unread-count/route.ts new file mode 100644 index 00000000..4431a003 --- /dev/null +++ b/src/app/api/notifications/unread-count/route.ts @@ -0,0 +1,5 @@ +import { NotificationController } from '@/modules/notification/notification.controller'; + +export async function GET(req: Request) { + return NotificationController.getUnreadCount(req); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9a5c8787..85b936a5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -10,6 +10,7 @@ import { DashboardStats } from './components/DashboardStats'; import { PerformanceChart } from './components/PerformanceChart'; import { ActivityFeed } from './components/ActivityFeed'; import ResumeCard from '@/components/ResumeCard'; +import NotificationCenter from '@/components/NotificationCenter'; interface UserProps { name: string; @@ -161,19 +162,7 @@ export default function DashboardPage() {

- - - +
diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx new file mode 100644 index 00000000..6488e0ba --- /dev/null +++ b/src/components/NotificationCenter.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import authFetch from '@/lib/auth/authFetch'; + +interface Notification { + id: string; + type: string; + title: string; + message: string; + isRead: boolean; + data?: Record; + createdAt: string; + readAt?: string | null; +} + +const notificationIcons: Record = { + QUIZ_REMINDER: ( + + + + + ), + GOAL_COMPLETION: ( + + + + + ), + STUDY_STREAK: ( + + + + + + ), + REVISION_RECOMMENDATION: ( + + + + + ), + PERFORMANCE_INSIGHT: ( + + + + + + ), + WEEKLY_PROGRESS: ( + + + + + + + ), + SYLLABUS_MILESTONE: ( + + + + + ), + QUIZ_COMPLETION: ( + + + + + + ), + DAILY_STUDY_REMINDER: ( + + + + + ), +}; + +const defaultIcon = ( + + + + +); + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return 'Just now'; + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}m ago`; + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}h ago`; + } else if (diffInSeconds < 604800) { + const days = Math.floor(diffInSeconds / 86400); + return `${days}d ago`; + } else { + return date.toLocaleDateString(); + } +} + +export function NotificationCenter() { + const [isOpen, setIsOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState<'all' | 'unread'>('all'); + const dropdownRef = useRef(null); + + const fetchNotifications = useCallback(async () => { + setIsLoading(true); + try { + const res = await authFetch({ + url: `/api/notifications?limit=20&offset=0&isRead=${activeTab === 'unread' ? 'false' : ''}`, + options: { method: 'GET' }, + }); + + if (res?.data) { + setNotifications(res.data.notifications || []); + setUnreadCount(res.data.unreadCount || 0); + } + } catch (error) { + console.error('Failed to fetch notifications:', error); + } finally { + setIsLoading(false); + } + }, [activeTab]); + + const markAsRead = async (id: string) => { + try { + await authFetch({ + url: `/api/notifications/${id}/read`, + options: { method: 'PATCH' }, + }); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + } catch (error) { + console.error('Failed to mark as read:', error); + } + }; + + const markAllAsRead = async () => { + try { + await authFetch({ + url: '/api/notifications/mark-all-read', + options: { method: 'POST' }, + }); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + setUnreadCount(0); + } catch (error) { + console.error('Failed to mark all as read:', error); + } + }; + + const deleteNotification = async (id: string) => { + try { + await authFetch({ + url: `/api/notifications/${id}`, + options: { method: 'DELETE' }, + }); + const deleted = notifications.find((n) => n.id === id); + setNotifications((prev) => prev.filter((n) => n.id !== id)); + if (deleted && !deleted.isRead) { + setUnreadCount((prev) => Math.max(0, prev - 1)); + } + } catch (error) { + console.error('Failed to delete notification:', error); + } + }; + + useEffect(() => { + fetchNotifications(); + }, [fetchNotifications]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + useEffect(() => { + const fetchUnreadCount = async () => { + try { + const res = await authFetch({ + url: '/api/notifications/unread-count', + options: { method: 'GET' }, + }); + if (res?.data?.unreadCount !== undefined) { + setUnreadCount(res.data.unreadCount); + } + } catch (error) { + console.error('Failed to fetch unread count:', error); + } + }; + + fetchUnreadCount(); + const interval = setInterval(fetchUnreadCount, 60000); + return () => clearInterval(interval); + }, []); + + const filteredNotifications = activeTab === 'unread' + ? notifications.filter((n) => !n.isRead) + : notifications; + + return ( +
+ {/* Bell Icon Button */} + + + {/* Notification Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ + {/* Tabs */} +
+ + +
+ + {/* Notification List */} +
+ {isLoading ? ( +
+
+
+ ) : filteredNotifications.length === 0 ? ( +
+ + + + +

No notifications yet

+
+ ) : ( +
+ {filteredNotifications.map((notification) => ( +
{ + if (!notification.isRead) { + markAsRead(notification.id); + } + }} + > + {/* Unread indicator */} + {!notification.isRead && ( + + )} + +
+ {/* Icon */} +
+ {notificationIcons[notification.type] || defaultIcon} +
+ + {/* Content */} +
+
+

+ {notification.title} +

+ +
+

+ {notification.message} +

+

+ {formatTimeAgo(notification.createdAt)} +

+
+
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+ )} +
+ ); +} + +export default NotificationCenter; diff --git a/src/modules/notification/index.ts b/src/modules/notification/index.ts new file mode 100644 index 00000000..ba3cae28 --- /dev/null +++ b/src/modules/notification/index.ts @@ -0,0 +1,4 @@ +export { NotificationController } from './notification.controller'; +export { NotificationService } from './notification.service'; +export { NotificationRepository } from './notification.repository'; +export * from './notification.types'; diff --git a/src/modules/notification/notification.controller.ts b/src/modules/notification/notification.controller.ts new file mode 100644 index 00000000..6ad8324a --- /dev/null +++ b/src/modules/notification/notification.controller.ts @@ -0,0 +1,165 @@ +import { NextResponse } from 'next/server'; +import { ZodError } from 'zod'; +import { withAuth, type AuthContext } from '@/lib/middleware/auth.middleware'; +import { NotificationService } from './notification.service'; +import { NotificationType } from '@/generated/prisma'; + +const handleError = (error: unknown) => { + if (error instanceof ZodError) { + return NextResponse.json( + { success: false, message: 'Invalid request body', errors: error.issues }, + { status: 400 } + ); + } + + const message = error instanceof Error ? error.message : 'Internal server error'; + + let status = 500; + if (message.includes('not found')) status = 404; + else if (message.includes('Unauthorized')) status = 403; + + return NextResponse.json({ success: false, message }, { status }); +}; + +function extractIdFromUrl(url: string): string | null { + const urlObj = new URL(url); + const pathSegments = urlObj.pathname.split('/').filter(Boolean); + // Expected path: /api/notifications/[id] + // pathSegments would be: ['api', 'notifications', '[id]'] or ['api', 'notifications', 'some-uuid'] + const notificationsIndex = pathSegments.indexOf('notifications'); + if (notificationsIndex !== -1 && pathSegments[notificationsIndex + 1]) { + const id = pathSegments[notificationsIndex + 1]; + if (id !== 'mark-all-read' && id !== 'unread-count') { + return id; + } + } + return null; +} + +export class NotificationController { + // GET /api/notifications - get all user's notifications + static getNotifications = withAuth(async (req: Request, auth: AuthContext) => { + try { + const { searchParams } = new URL(req.url); + const isRead = searchParams.get('isRead'); + const type = searchParams.get('type') as NotificationType | null; + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = parseInt(searchParams.get('offset') || '0'); + + const result = await NotificationService.getNotifications({ + userId: auth.userId, + isRead: isRead === 'true' ? true : isRead === 'false' ? false : undefined, + type: type || undefined, + limit, + offset, + }); + + return NextResponse.json({ success: true, data: result }); + } catch (error) { + return handleError(error); + } + }); + + // GET /api/notifications/unread-count - get unread notification count + static getUnreadCount = withAuth(async (_req: Request, auth: AuthContext) => { + try { + const count = await NotificationService.getUnreadCount(auth.userId); + return NextResponse.json({ success: true, data: { unreadCount: count } }); + } catch (error) { + return handleError(error); + } + }); + + // POST /api/notifications - create a notification (admin/system use) + static createNotification = withAuth(async (req: Request, auth: AuthContext) => { + try { + const body = await req.json(); + const { userId, type, title, message, data } = body; + + if (!userId || !type || !title || !message) { + return NextResponse.json( + { success: false, message: 'userId, type, title, and message are required' }, + { status: 400 } + ); + } + + // Validate notification type + const validTypes = Object.values(NotificationType); + if (!validTypes.includes(type)) { + return NextResponse.json( + { success: false, message: `Invalid notification type. Must be one of: ${validTypes.join(', ')}` }, + { status: 400 } + ); + } + + const notification = await NotificationService.createNotification({ + userId, + type, + title, + message, + data, + }); + + return NextResponse.json( + { success: true, message: 'Notification created successfully', data: notification }, + { status: 201 } + ); + } catch (error) { + return handleError(error); + } + }); + + // PATCH /api/notifications/[id] - mark notification as read + static markAsRead = withAuth(async (req: Request, auth: AuthContext) => { + try { + const id = extractIdFromUrl(req.url); + if (!id) { + return NextResponse.json( + { success: false, message: 'Notification ID is required' }, + { status: 400 } + ); + } + const result = await NotificationService.markAsRead(id, auth.userId); + return NextResponse.json({ success: true, data: result }); + } catch (error) { + return handleError(error); + } + }); + + // POST /api/notifications/mark-all-read - mark all notifications as read + static markAllAsRead = withAuth(async (_req: Request, auth: AuthContext) => { + try { + const result = await NotificationService.markAllAsRead(auth.userId); + return NextResponse.json({ success: true, data: result }); + } catch (error) { + return handleError(error); + } + }); + + // DELETE /api/notifications/[id] - delete a notification + static deleteNotification = withAuth(async (req: Request, auth: AuthContext) => { + try { + const id = extractIdFromUrl(req.url); + if (!id) { + return NextResponse.json( + { success: false, message: 'Notification ID is required' }, + { status: 400 } + ); + } + const result = await NotificationService.deleteNotification(id, auth.userId); + return NextResponse.json({ success: true, data: result }); + } catch (error) { + return handleError(error); + } + }); + + // DELETE /api/notifications - delete all read notifications + static deleteAllRead = withAuth(async (_req: Request, auth: AuthContext) => { + try { + const result = await NotificationService.deleteAllRead(auth.userId); + return NextResponse.json({ success: true, data: result }); + } catch (error) { + return handleError(error); + } + }); +} diff --git a/src/modules/notification/notification.repository.ts b/src/modules/notification/notification.repository.ts new file mode 100644 index 00000000..ccd5a056 --- /dev/null +++ b/src/modules/notification/notification.repository.ts @@ -0,0 +1,95 @@ +import { prisma } from '@/lib/prisma'; +import type { NotificationFilters, CreateNotificationInput } from './notification.types'; + +export class NotificationRepository { + static async findByUser(filters: NotificationFilters) { + const { userId, isRead, type, limit = 20, offset = 0 } = filters; + + const where: Record = { userId }; + if (isRead !== undefined) { + where.isRead = isRead; + } + if (type) { + where.type = type; + } + + const [notifications, total, unreadCount] = await Promise.all([ + prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }), + prisma.notification.count({ where }), + prisma.notification.count({ where: { userId, isRead: false } }), + ]); + + return { notifications, total, unreadCount }; + } + + static async findById(id: string) { + return prisma.notification.findUnique({ + where: { id }, + }); + } + + static async create(input: CreateNotificationInput) { + return prisma.notification.create({ + data: { + userId: input.userId, + type: input.type, + title: input.title, + message: input.message, + data: input.data, + }, + }); + } + + static async markAsRead(id: string, userId: string) { + return prisma.notification.updateMany({ + where: { id, userId }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + } + + static async markAllAsRead(userId: string) { + return prisma.notification.updateMany({ + where: { userId, isRead: false }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + } + + static async delete(id: string, userId: string) { + const notification = await this.findById(id); + + if (!notification) { + throw new Error('Notification not found'); + } + + if (notification.userId !== userId) { + throw new Error('Unauthorized to delete this notification'); + } + + return prisma.notification.delete({ + where: { id }, + }); + } + + static async deleteAllRead(userId: string) { + return prisma.notification.deleteMany({ + where: { userId, isRead: true }, + }); + } + + static async getUnreadCount(userId: string) { + return prisma.notification.count({ + where: { userId, isRead: false }, + }); + } +} diff --git a/src/modules/notification/notification.service.ts b/src/modules/notification/notification.service.ts new file mode 100644 index 00000000..93247c11 --- /dev/null +++ b/src/modules/notification/notification.service.ts @@ -0,0 +1,156 @@ +import { NotificationRepository } from './notification.repository'; +import type { NotificationFilters, CreateNotificationInput } from './notification.types'; + +export class NotificationService { + static async getNotifications(filters: NotificationFilters) { + const result = await NotificationRepository.findByUser(filters); + return { + notifications: result.notifications, + unreadCount: result.unreadCount, + total: result.total, + }; + } + + static async getUnreadCount(userId: string) { + return NotificationRepository.getUnreadCount(userId); + } + + static async createNotification(input: CreateNotificationInput) { + return NotificationRepository.create(input); + } + + static async markAsRead(id: string, userId: string) { + const notification = await NotificationRepository.findById(id); + + if (!notification) { + throw new Error('Notification not found'); + } + + if (notification.userId !== userId) { + throw new Error('Unauthorized to mark this notification as read'); + } + + return NotificationRepository.markAsRead(id, userId); + } + + static async markAllAsRead(userId: string) { + return NotificationRepository.markAllAsRead(userId); + } + + static async deleteNotification(id: string, userId: string) { + return NotificationRepository.delete(id, userId); + } + + static async deleteAllRead(userId: string) { + return NotificationRepository.deleteAllRead(userId); + } + + // Helper methods for creating specific notification types + static async createQuizReminder(userId: string, quizTitle: string, dueDate?: Date) { + const message = dueDate + ? `Your quiz "${quizTitle}" is due on ${dueDate.toLocaleDateString()}. Don't forget to complete it!` + : `Time to practice! Take a quiz on "${quizTitle}" to strengthen your understanding.`; + + return this.createNotification({ + userId, + type: 'QUIZ_REMINDER', + title: 'Quiz Reminder', + message, + data: { quizTitle, dueDate }, + }); + } + + static async createGoalCompletion(userId: string, goalTitle: string, achievement: string) { + return this.createNotification({ + userId, + type: 'GOAL_COMPLETION', + title: 'Goal Achieved! 🎉', + message: `Congratulations! You've completed your goal: "${goalTitle}". ${achievement}`, + data: { goalTitle, achievement }, + }); + } + + static async createStreakAlert(userId: string, currentStreak: number) { + const streakMessages: Record = { + 7: "A whole week! Your dedication is inspiring.", + 14: "Two weeks strong! You're building excellent habits.", + 30: "A month of consistent learning! You're unstoppable.", + 60: "Two months! You've mastered the art of consistency.", + 100: "100 days! You're a learning champion!", + }; + + const message = streakMessages[currentStreak] || `Keep it up! You're on a ${currentStreak}-day streak!`; + + return this.createNotification({ + userId, + type: 'STUDY_STREAK', + title: 'Study Streak Milestone', + message, + data: { streak: currentStreak }, + }); + } + + static async createRevisionRecommendation(userId: string, topic: string, reason: string) { + return this.createNotification({ + userId, + type: 'REVISION_RECOMMENDATION', + title: 'Time to Review', + message: `We noticed you might benefit from reviewing "${topic}". ${reason}`, + data: { topic, reason }, + }); + } + + static async createPerformanceInsight(userId: string, insight: string, improvement: string) { + return this.createNotification({ + userId, + type: 'PERFORMANCE_INSIGHT', + title: 'Performance Insight', + message: `${insight}. Here's a tip: ${improvement}`, + }); + } + + static async createWeeklyProgress(userId: string, hoursStudied: number, accuracy: number) { + return this.createNotification({ + userId, + type: 'WEEKLY_PROGRESS', + title: 'Weekly Progress Report', + message: `This week: ${hoursStudied} hours of study time with ${accuracy}% accuracy. Keep up the great work!`, + data: { hoursStudied, accuracy }, + }); + } + + static async createSyllabusMilestone(userId: string, chapterName: string, progress: number) { + return this.createNotification({ + userId, + type: 'SYLLABUS_MILESTONE', + title: 'Chapter Complete! 📚', + message: `You've completed ${progress}% of "${chapterName}". Keep pushing forward!`, + data: { chapterName, progress }, + }); + } + + static async createQuizCompletion(userId: string, quizTitle: string, score: number, accuracy: number) { + const emoji = accuracy >= 80 ? '🌟' : accuracy >= 60 ? '👍' : '💪'; + return this.createNotification({ + userId, + type: 'QUIZ_COMPLETION', + title: `Quiz Complete ${emoji}`, + message: `You scored ${score}/${accuracy}% on "${quizTitle}". ${accuracy >= 80 ? 'Excellent work!' : 'Keep practicing!'}`, + data: { quizTitle, score, accuracy }, + }); + } + + static async createDailyStudyReminder(userId: string, suggestedTopic?: string) { + const message = suggestedTopic + ? `Start your day strong! We suggest studying "${suggestedTopic}" today.` + : "Good morning! A new day of learning awaits. Let's build your streak!"; + + return this.createNotification({ + userId, + type: 'DAILY_STUDY_REMINDER', + title: 'Daily Study Reminder', + message, + data: { suggestedTopic }, + }); + } +} diff --git a/src/modules/notification/notification.types.ts b/src/modules/notification/notification.types.ts new file mode 100644 index 00000000..45e5f23d --- /dev/null +++ b/src/modules/notification/notification.types.ts @@ -0,0 +1,35 @@ +import { NotificationType } from '@/generated/prisma'; + +export interface Notification { + id: string; + userId: string; + type: NotificationType; + title: string; + message: string; + isRead: boolean; + data?: Record; + createdAt: Date; + readAt?: Date | null; +} + +export interface CreateNotificationInput { + userId: string; + type: NotificationType; + title: string; + message: string; + data?: Record; +} + +export interface NotificationFilters { + userId: string; + isRead?: boolean; + type?: NotificationType; + limit?: number; + offset?: number; +} + +export interface NotificationResponse { + success: boolean; + message?: string; + data?: Notification | Notification[] | { notifications: Notification[]; unreadCount: number; total: number }; +} diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 040ecc6f..de5debbc 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { refreshTokens RefreshToken[] stats UserStats? bookmarks Bookmark[] + notifications Notification[] @@index([email]) } @@ -261,6 +262,34 @@ model Bookmark { @@index([chapterId]) } +model Notification { + id String @id @default(uuid()) + userId String + type NotificationType + title String + message String + isRead Boolean @default(false) + data Json? + createdAt DateTime @default(now()) + readAt DateTime? + + @@index([userId]) + @@index([userId, isRead]) + @@index([createdAt]) +} + +enum NotificationType { + QUIZ_REMINDER + GOAL_COMPLETION + STUDY_STREAK + REVISION_RECOMMENDATION + PERFORMANCE_INSIGHT + WEEKLY_PROGRESS + SYLLABUS_MILESTONE + QUIZ_COMPLETION + DAILY_STUDY_REMINDER +} + enum UserRole { STUDENT