feat(notifications): Added notification system with mention support
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-21 02:36:02 -08:00
parent 1ece5a037e
commit a4edbb676d
5 changed files with 260 additions and 62 deletions

19
backend/helpers.py Normal file
View file

@ -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

View file

@ -1,88 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, desc
from database import get_session from database import get_session
from models import Notification, User from models import Notification, User
from schemas import NotificationRead, NotificationCreate from auth import oauth2_scheme, get_current_user
from auth import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(
prefix="/notifications",
tags=["notifications"]
)
@router.get("/", response_model=List[NotificationRead]) @router.get("/", response_model=List[Notification])
def read_notifications( def get_my_notifications(
session: Session = Depends(get_session), skip: int = 0,
current_user: User = Depends(get_current_user),
limit: int = 20, limit: int = 20,
offset: int = 0 current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
): ):
notifications = session.exec( statement = (
select(Notification) select(Notification)
.where(Notification.user_id == current_user.id) .where(Notification.user_id == current_user.id)
.order_by(desc(Notification.created_at)) .order_by(Notification.created_at.desc())
.offset(offset) .offset(skip)
.limit(limit) .limit(limit)
).all() )
return notifications return session.exec(statement).all()
@router.get("/unread-count") @router.get("/unread-count")
def 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 statement = (
count = session.exec( select(Notification)
select(func.count(Notification.id))
.where(Notification.user_id == current_user.id) .where(Notification.user_id == current_user.id)
.where(Notification.is_read == False) .where(Notification.is_read == False)
).one() )
return {"count": count} results = session.exec(statement).all()
return {"count": len(results)}
@router.post("/{notification_id}/read") @router.post("/{notification_id}/read")
def mark_as_read( def mark_notification_read(
notification_id: int, notification_id: int,
session: Session = Depends(get_session), current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user) session: Session = Depends(get_session)
): ):
notification = session.get(Notification, notification_id) notification = session.get(Notification, notification_id)
if not notification: if not notification:
raise HTTPException(status_code=404, detail="Notification not found") raise HTTPException(status_code=404, detail="Notification not found")
if notification.user_id != current_user.id: if notification.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized") raise HTTPException(status_code=403, detail="Not authorized")
notification.is_read = True notification.is_read = True
session.add(notification) session.add(notification)
session.commit() session.commit()
return {"ok": True} session.refresh(notification)
return notification
@router.post("/mark-all-read") @router.post("/mark-all-read")
def mark_all_read( def mark_all_read(
session: Session = Depends(get_session), current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user) session: Session = Depends(get_session)
): ):
notifications = session.exec( statement = (
select(Notification) select(Notification)
.where(Notification.user_id == current_user.id) .where(Notification.user_id == current_user.id)
.where(Notification.is_read == False) .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
) )
notifications = session.exec(statement).all()
for notification in notifications:
notification.is_read = True
session.add(notification) session.add(notification)
session.commit() session.commit()
session.refresh(notification) return {"status": "success", "marked_count": len(notifications)}
return notification

View file

@ -1,10 +1,12 @@
import re
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func from sqlmodel import Session, select, func
from database import get_session 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 schemas import CommentCreate, CommentRead, RatingCreate, RatingRead
from auth import get_current_user from auth import get_current_user
from helpers import create_notification
router = APIRouter(prefix="/social", tags=["social"]) 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) # Notify parent author if reply (TODO: Add parent_id to Comment model)
# For now, let's just log it or skip. # 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 return db_comment
@router.get("/comments", response_model=List[CommentRead]) @router.get("/comments", response_model=List[CommentRead])

View file

@ -3,7 +3,7 @@ import Link from "next/link"
import { Music, User, ChevronDown } from "lucide-react" import { Music, User, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SearchDialog } from "@/components/ui/search-dialog" import { SearchDialog } from "@/components/ui/search-dialog"
import { NotificationBell } from "@/components/layout/notification-bell" import { NotificationBell } from "@/components/notifications/notification-bell"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View file

@ -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<Notification[]>([])
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 (
<DropdownMenu open={isOpen} onOpenChange={(open) => {
setIsOpen(open)
if (open) fetchNotifications()
}}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 h-2.5 w-2.5 rounded-full bg-red-600 border-2 border-background" />
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="font-semibold text-sm">Notifications</span>
{unreadCount > 0 && (
<Button
variant="ghost"
size="xs"
className="h-auto px-2 py-0.5 text-xs text-muted-foreground hover:text-primary"
onClick={handleMarkAllRead}
>
Mark all read
</Button>
)}
</div>
<ScrollArea className="h-[300px]">
{notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications yet.
</div>
) : (
<div className="flex flex-col">
{notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"flex flex-col gap-1 p-4 border-b text-left transition-colors hover:bg-muted/50 cursor-pointer",
!notification.is_read && "bg-muted/30"
)}
onClick={() => handleMarkAsRead(notification.id)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("text-sm font-medium leading-none", !notification.is_read && "text-primary")}>
{notification.title}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.message}
</p>
{notification.link && (
<Link
href={notification.link}
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
View <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
))}
</div>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
}