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 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),
|
||||
@router.get("/", response_model=List[Notification])
|
||||
def get_my_notifications(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
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(
|
||||
def mark_notification_read(
|
||||
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)
|
||||
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}
|
||||
session.refresh(notification)
|
||||
return notification
|
||||
|
||||
@router.post("/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)
|
||||
.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
|
||||
)
|
||||
session.add(notification)
|
||||
notifications = session.exec(statement).all()
|
||||
|
||||
for notification in notifications:
|
||||
notification.is_read = True
|
||||
session.add(notification)
|
||||
|
||||
session.commit()
|
||||
session.refresh(notification)
|
||||
return notification
|
||||
return {"status": "success", "marked_count": len(notifications)}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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