feat(frontend): Benachrichtigungssystem (Modul 26)

NotificationBell in der Kopfzeile: ungelesene Anzahl als Badge,
Dropdown mit Prioritaets-Badges (INFO/HINWEIS/WARNUNG/KRITISCH),
Klick markiert als gelesen und fuehrt zum verknuepften Objekt.
Ausserdem IDOR im Backend gefunden und behoben (siehe vorheriger
Commit) - live mit einem echten Datensatz verifiziert.
This commit is contained in:
Nick Szaunig committed 2026-08-22 22:50:45 +02:00
1 parent 75f0d4fd51
commit 0fc13952fb
5 files changed
+125 -2

No files matched your search

+3 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Hexagon, MoreHorizontal } from "lucide-react"; import { Hexagon, MoreHorizontal } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { NotificationBell } from "@/components/notification-bell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -104,7 +105,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</nav> </nav>
<div className="flex shrink-0 items-center gap-3 text-sm"> <div className="flex shrink-0 items-center gap-2 text-sm">
<NotificationBell />
<span className="hidden max-w-40 truncate text-muted-foreground md:inline"> <span className="hidden max-w-40 truncate text-muted-foreground md:inline">
{user.email} {user.email}
</span> </span>
@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell } from "lucide-react";
import { fetchNotifications, markNotificationRead } from "@/lib/notifications/api";
import { Notification, NotificationPriority } from "@/lib/notifications/types";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const PRIORITY_VARIANT: Record<NotificationPriority, NonNullable<Parameters<typeof badgeVariants>[0]>["variant"]> = {
INFO: "secondary",
HINWEIS: "outline",
WARNUNG: "warning",
KRITISCH: "destructive",
};
function notificationHref(notification: Notification): string | null {
if (notification.relatedType === "Colony") return `/colonies/${notification.relatedId}`;
if (notification.relatedType === "Apiary") return `/apiaries/${notification.relatedId}`;
if (notification.relatedType === "Task") return "/tasks";
return null;
}
export function NotificationBell() {
const queryClient = useQueryClient();
const { data: notifications } = useQuery({
queryKey: ["notifications"],
queryFn: () => fetchNotifications(),
refetchInterval: 60_000,
});
const readMutation = useMutation({
mutationFn: markNotificationRead,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
const unreadCount = notifications?.filter((notification) => !notification.read).length ?? 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen" />
}
>
<Bell className="size-4.5" />
{unreadCount > 0 ? (
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
) : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{!notifications || notifications.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Keine Benachrichtigungen.</p>
) : (
notifications.slice(0, 10).map((notification) => {
const href = notificationHref(notification);
const item = (
<div
className={`flex flex-col gap-1 p-2 text-sm ${notification.read ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between gap-2">
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
{notification.priority}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(notification.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
<span>{notification.message}</span>
</div>
);
return (
<DropdownMenuItem
key={notification.id}
className="flex-col items-stretch p-0"
onClick={() => {
if (!notification.read) readMutation.mutate(notification.id);
}}
render={href ? <Link href={href} /> : undefined}
>
{item}
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client";
import { Notification } from "./types";
export function fetchNotifications(unreadOnly = false): Promise<Notification[]> {
return apiFetch<Notification[]>(`/notifications?unreadOnly=${unreadOnly}`);
}
export function markNotificationRead(id: string): Promise<Notification> {
return apiFetch<Notification>(`/notifications/${id}/read`, { method: "POST" });
}
@@ -0,0 +1,11 @@
export type NotificationPriority = "INFO" | "HINWEIS" | "WARNUNG" | "KRITISCH";
export interface Notification {
id: string;
priority: NotificationPriority;
message: string;
relatedType: string | null;
relatedId: string | null;
read: boolean;
createdAt: string;
}
+1 -1
View File
@@ -212,7 +212,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li
- Endpunkte: `GET /api/v1/notifications`, `POST /api/v1/notifications/:id/read` - Endpunkte: `GET /api/v1/notifications`, `POST /api/v1/notifications/:id/read`
- Frontend: Glocken-Icon in der Navigation mit Liste/Badge für ungelesene Einträge - Frontend: Glocken-Icon in der Navigation mit Liste/Badge für ungelesene Einträge
- [x] Backend-Modul fertig, getestet, grün, committet - [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet - [x] Frontend-Views fertig, getestet, grün, committet
### 27. Statistik und Reporting ### 27. Statistik und Reporting