diff --git a/backend/helpers.py b/backend/helpers.py new file mode 100644 index 0000000..ac72184 --- /dev/null +++ b/backend/helpers.py @@ -0,0 +1,19 @@ +from sqlmodel import Session +from models import Notification + +def create_notification(session: Session, user_id: int, title: str, message: str, type: str, link: str = None): + notification = Notification( + user_id=user_id, + title=title, + message=message, + type=type, + link=link + ) + session.add(notification) + # Note: caller is responsible for commit if part of larger transaction, + # but for simple notification triggers, we can commit here or let caller do it. + # To be safe and atomic, usually we add to session and let caller commit. + # But for a helper, instant commit ensures specific notification persistence. + session.commit() + session.refresh(notification) + return notification diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py index d4677ce..2e1f318 100644 --- a/backend/routers/notifications.py +++ b/backend/routers/notifications.py @@ -1,88 +1,77 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlmodel import Session, select, desc from database import get_session from models import Notification, User -from schemas import NotificationRead, NotificationCreate -from auth import get_current_user +from auth import oauth2_scheme, get_current_user -router = APIRouter(prefix="/notifications", tags=["notifications"]) +router = APIRouter( + prefix="/notifications", + tags=["notifications"] +) -@router.get("/", response_model=List[NotificationRead]) -def read_notifications( - session: Session = Depends(get_session), - current_user: User = Depends(get_current_user), - limit: int = 20, - offset: int = 0 +@router.get("/", response_model=List[Notification]) +def get_my_notifications( + skip: int = 0, + limit: int = 20, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) ): - notifications = session.exec( + statement = ( select(Notification) .where(Notification.user_id == current_user.id) - .order_by(desc(Notification.created_at)) - .offset(offset) + .order_by(Notification.created_at.desc()) + .offset(skip) .limit(limit) - ).all() - return notifications + ) + return session.exec(statement).all() @router.get("/unread-count") def get_unread_count( - session: Session = Depends(get_session), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) ): - from sqlmodel import func - count = session.exec( - select(func.count(Notification.id)) + statement = ( + select(Notification) .where(Notification.user_id == current_user.id) .where(Notification.is_read == False) - ).one() - return {"count": count} + ) + results = session.exec(statement).all() + return {"count": len(results)} @router.post("/{notification_id}/read") -def mark_as_read( - notification_id: int, - session: Session = Depends(get_session), - current_user: User = Depends(get_current_user) +def mark_notification_read( + notification_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) ): notification = session.get(Notification, notification_id) if not notification: raise HTTPException(status_code=404, detail="Notification not found") - if notification.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") - - notification.is_read = True - session.add(notification) - session.commit() - return {"ok": True} - -@router.post("/mark-all-read") -def mark_all_read( - session: Session = Depends(get_session), - current_user: User = Depends(get_current_user) -): - notifications = session.exec( - select(Notification) - .where(Notification.user_id == current_user.id) - .where(Notification.is_read == False) - ).all() - for n in notifications: - n.is_read = True - session.add(n) - - session.commit() - return {"ok": True} - -# Helper function to create notifications (not an endpoint) -def create_notification(session: Session, user_id: int, type: str, title: str, message: str, link: str = None): - notification = Notification( - user_id=user_id, - type=type, - title=title, - message=message, - link=link - ) + notification.is_read = True session.add(notification) session.commit() session.refresh(notification) return notification + +@router.post("/mark-all-read") +def mark_all_read( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + statement = ( + select(Notification) + .where(Notification.user_id == current_user.id) + .where(Notification.is_read == False) + ) + notifications = session.exec(statement).all() + + for notification in notifications: + notification.is_read = True + session.add(notification) + + session.commit() + return {"status": "success", "marked_count": len(notifications)} diff --git a/backend/routers/social.py b/backend/routers/social.py index c0a0027..b8daf68 100644 --- a/backend/routers/social.py +++ b/backend/routers/social.py @@ -1,10 +1,12 @@ +import re from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select, func from database import get_session -from models import Comment, Rating, User +from models import Comment, Rating, User, Profile from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead from auth import get_current_user +from helpers import create_notification router = APIRouter(prefix="/social", tags=["social"]) @@ -25,6 +27,23 @@ def create_comment( # Notify parent author if reply (TODO: Add parent_id to Comment model) # For now, let's just log it or skip. + # Handle Mentions + mention_pattern = r"@(\w+)" + mentions = re.findall(mention_pattern, db_comment.content) + if mentions: + # Find users with these profile usernames + mentioned_profiles = session.exec(select(Profile).where(Profile.username.in_(mentions))).all() + for profile in mentioned_profiles: + if profile.user_id != current_user.id: + create_notification( + session, + user_id=profile.user_id, + title="You were mentioned!", + message=f"Someone mentioned you in a comment.", + type="mention", + link=f"/activity" # Generic link for now + ) + return db_comment @router.get("/comments", response_model=List[CommentRead]) diff --git a/frontend/components/layout/navbar.tsx b/frontend/components/layout/navbar.tsx index 2290810..4695ffa 100644 --- a/frontend/components/layout/navbar.tsx +++ b/frontend/components/layout/navbar.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { Music, User, ChevronDown } from "lucide-react" import { Button } from "@/components/ui/button" import { SearchDialog } from "@/components/ui/search-dialog" -import { NotificationBell } from "@/components/layout/notification-bell" +import { NotificationBell } from "@/components/notifications/notification-bell" import { DropdownMenu, DropdownMenuContent, diff --git a/frontend/components/notifications/notification-bell.tsx b/frontend/components/notifications/notification-bell.tsx new file mode 100644 index 0000000..9946d79 --- /dev/null +++ b/frontend/components/notifications/notification-bell.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useState, useEffect } from "react" +import { Bell, Check, ExternalLink } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuHeader, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator +} from "@/components/ui/dropdown-menu" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { getApiUrl } from "@/lib/api-config" +import Link from "next/link" +import { formatDistanceToNow } from "date-fns" +import { cn } from "@/lib/utils" + +interface Notification { + id: number + type: string + title: string + message: string + link: string | null + is_read: boolean + created_at: string +} + +export function NotificationBell() { + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [isOpen, setIsOpen] = useState(false) + const [loading, setLoading] = useState(false) + + const fetchNotifications = async () => { + const token = localStorage.getItem("token") + if (!token) return + + setLoading(true) + try { + const res = await fetch(`${getApiUrl()}/notifications/`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (res.ok) { + const data = await res.json() + setNotifications(data) + setUnreadCount(data.filter((n: Notification) => !n.is_read).length) + } + } catch (error) { + console.error("Failed to fetch notifications", error) + } finally { + setLoading(false) + } + } + + // Determine when to fetch: Mount and when opened + useEffect(() => { + fetchNotifications() + // Optional: Poll every 60s + const interval = setInterval(fetchNotifications, 60000) + return () => clearInterval(interval) + }, []) + + const handleMarkAsRead = async (id: number) => { + const token = localStorage.getItem("token") + if (!token) return + + try { + await fetch(`${getApiUrl()}/notifications/${id}/read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` } + }) + // Optimistic update + setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n)) + setUnreadCount(prev => Math.max(0, prev - 1)) + } catch (error) { + console.error("Failed to mark as read", error) + } + } + + const handleMarkAllRead = async () => { + const token = localStorage.getItem("token") + if (!token) return + + try { + await fetch(`${getApiUrl()}/notifications/mark-all-read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` } + }) + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))) + setUnreadCount(0) + } catch (error) { + console.error("Failed to mark all as read", error) + } + } + + return ( + { + setIsOpen(open) + if (open) fetchNotifications() + }}> + + + + +
+ Notifications + {unreadCount > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +
+ No notifications yet. +
+ ) : ( +
+ {notifications.map((notification) => ( +
handleMarkAsRead(notification.id)} + > +
+ + {notification.title} + + + {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} + +
+

+ {notification.message} +

+ {notification.link && ( + e.stopPropagation()} + > + View + + )} +
+ ))} +
+ )} +
+
+
+ ) +}