feat(notifications): Added notification system with mention support
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
1ece5a037e
commit
a4edbb676d
5 changed files with 260 additions and 62 deletions
19
backend/helpers.py
Normal file
19
backend/helpers.py
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
171
frontend/components/notifications/notification-bell.tsx
Normal file
171
frontend/components/notifications/notification-bell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue