feat: add real-time notification system and fix admin login bug

- Fix login: role field was missing from session (admin page inaccessible)
- Add onlineUsers map in socket/state.ts to track connected users
- Join personal user:${id} socket room on connect for targeted notifications
- Emit friendRequestReceived/friendRequestAccepted socket events from friends controller
- Add sendGameInvite socket event handler on server
- New NotificationContext: global socket provider for registered users
- New NotificationPanel: bell icon with unread badge and dropdown in header
- Friends page: inline game invite input per friend with sendGameInvite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-14 12:35:09 +02:00
parent 1501b859b2
commit 4825a57a54
10 changed files with 267 additions and 11 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { API_URL } from "@/config"; import { API_URL } from "@/config";
import { useNotifications } from "@/context/NotificationContext";
import { useSession } from "@/context/session"; import { useSession } from "@/context/session";
interface Friend { interface Friend {
@@ -29,10 +30,13 @@ interface FriendRequest {
export default function FriendsPage() { export default function FriendsPage() {
const { user } = useSession(); const { user } = useSession();
const router = useRouter(); const router = useRouter();
const { sendGameInvite } = useNotifications();
const [friends, setFriends] = useState<Friend[]>([]); const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] }); const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] });
const [addUsername, setAddUsername] = useState(""); const [addUsername, setAddUsername] = useState("");
const [addMsg, setAddMsg] = useState(""); const [addMsg, setAddMsg] = useState("");
const [inviteCodes, setInviteCodes] = useState<Record<number, string>>({});
const [inviteSent, setInviteSent] = useState<Record<number, boolean>>({});
useEffect(() => { useEffect(() => {
if (user === null) router.push("/"); if (user === null) router.push("/");
@@ -146,7 +150,8 @@ export default function FriendsPage() {
<h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2> <h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2>
{friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>} {friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>}
{friends.map((f) => ( {friends.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2"> <div key={f.id} className="flex flex-col gap-1 py-1 border-b border-base-300 last:border-0">
<div className="flex items-center justify-between gap-2">
<Link href={`/user/${f.friend_name}`} className="font-medium link link-hover"> <Link href={`/user/${f.friend_name}`} className="font-medium link link-hover">
{f.friend_name} {f.friend_name}
</Link> </Link>
@@ -155,6 +160,28 @@ export default function FriendsPage() {
Entfernen Entfernen
</button> </button>
</div> </div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Spielcode eingeben…"
className="input input-bordered input-xs flex-1"
value={inviteCodes[f.friend_id] ?? ""}
onChange={(e) => setInviteCodes((prev) => ({ ...prev, [f.friend_id]: e.target.value }))}
/>
<button
className="btn btn-xs btn-secondary"
disabled={!inviteCodes[f.friend_id]?.trim()}
onClick={() => {
sendGameInvite(f.friend_id, inviteCodes[f.friend_id].trim());
setInviteSent((prev) => ({ ...prev, [f.friend_id]: true }));
setInviteCodes((prev) => ({ ...prev, [f.friend_id]: "" }));
setTimeout(() => setInviteSent((prev) => ({ ...prev, [f.friend_id]: false })), 3000);
}}
>
{inviteSent[f.friend_id] ? "✓ Gesendet" : "Einladen"}
</button>
</div>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { IconUser, IconShield } from "@tabler/icons-react"; import { IconUser, IconShield } from "@tabler/icons-react";
import Link from "next/link"; import Link from "next/link";
import NotificationPanel from "./NotificationPanel";
import ThemeToggle from "./ThemeToggle"; import ThemeToggle from "./ThemeToggle";
import { useSession } from "@/context/session"; import { useSession } from "@/context/session";
@@ -35,6 +36,7 @@ export default function Header() {
Admin Admin
</Link> </Link>
)} )}
{user?.id && typeof user.id === "number" && <NotificationPanel />}
<ThemeToggle /> <ThemeToggle />
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar"> <label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full"> <div className="w-10 rounded-full">

View File

@@ -0,0 +1,80 @@
"use client";
import type { AppNotification } from "@michess/types";
import Link from "next/link";
import { useNotifications } from "@/context/NotificationContext";
function NotificationItem({ n, onDismiss }: { n: AppNotification; onDismiss: () => void }) {
let text = "";
if (n.type === "friendRequest") text = `${n.fromName} hat dir eine Freundschaftsanfrage gesendet.`;
if (n.type === "friendAccepted") text = `${n.fromName} hat deine Freundschaftsanfrage angenommen.`;
if (n.type === "gameInvite") text = `${n.fromName} lädt dich zu einem Spiel ein.`;
return (
<div className={`flex items-start gap-2 p-2 rounded-lg ${!n.read ? "bg-base-300" : ""}`}>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug">{text}</p>
</div>
{n.type === "gameInvite" && n.gameCode && (
<Link href={`/${n.gameCode}`} className="btn btn-xs btn-primary shrink-0" onClick={onDismiss}>
Beitreten
</Link>
)}
{n.type === "friendRequest" && (
<Link href="/friends" className="btn btn-xs btn-outline shrink-0" onClick={onDismiss}>
Ansehen
</Link>
)}
<button className="btn btn-xs btn-ghost shrink-0" onClick={onDismiss} aria-label="Schließen">
</button>
</div>
);
}
export default function NotificationPanel() {
const { notifications, unreadCount, markAllRead, dismiss } = useNotifications();
return (
<div className="dropdown dropdown-end">
<button
tabIndex={0}
className="btn btn-ghost btn-circle relative"
onClick={markAllRead}
aria-label="Benachrichtigungen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{unreadCount > 0 && (
<span className="badge badge-xs badge-primary absolute top-1 right-1">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<div
tabIndex={0}
className="dropdown-content shadow bg-base-200 rounded-box w-80 max-h-96 overflow-y-auto z-50 p-2 flex flex-col gap-1 mt-1"
>
<p className="text-xs font-semibold opacity-60 px-1 pb-1">Benachrichtigungen</p>
{notifications.length === 0 && (
<p className="text-sm opacity-50 px-2 py-3 text-center">Keine Benachrichtigungen</p>
)}
{notifications.map((n) => (
<NotificationItem key={n.id} n={n} onDismiss={() => dismiss(n.id)} />
))}
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchSession } from "@/lib/auth"; import { fetchSession } from "@/lib/auth";
import NotificationProvider from "./NotificationContext";
import { SessionContext } from "./session"; import { SessionContext } from "./session";
export default function ContextProvider({ children }: { children: ReactNode }) { export default function ContextProvider({ children }: { children: ReactNode }) {
@@ -19,5 +20,9 @@ export default function ContextProvider({ children }: { children: ReactNode }) {
getSession(); getSession();
}, []); }, []);
return <SessionContext.Provider value={{ user, setUser }}>{children}</SessionContext.Provider>; return (
<SessionContext.Provider value={{ user, setUser }}>
<NotificationProvider>{children}</NotificationProvider>
</SessionContext.Provider>
);
} }

View File

@@ -0,0 +1,87 @@
"use client";
import type { AppNotification } from "@michess/types";
import type { ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { io, type Socket } from "socket.io-client";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface NotificationContextValue {
notifications: AppNotification[];
unreadCount: number;
markAllRead: () => void;
dismiss: (id: string) => void;
sendGameInvite: (toId: number, gameCode: string) => void;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
export const useNotifications = () => {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
return ctx;
};
const notifSocket: Socket = io(API_URL, { withCredentials: true, autoConnect: false });
export default function NotificationProvider({ children }: { children: ReactNode }) {
const { user } = useSession();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const unreadCount = notifications.filter((n) => !n.read).length;
useEffect(() => {
if (!user?.id || typeof user.id === "string") {
if (notifSocket.connected) notifSocket.disconnect();
return;
}
notifSocket.connect();
notifSocket.on("friendRequestReceived", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "friendRequest", fromId, fromName, createdAt: Date.now(), read: false },
...prev
]);
});
notifSocket.on("friendRequestAccepted", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "friendAccepted", fromId, fromName, createdAt: Date.now(), read: false },
...prev
]);
});
notifSocket.on("gameInviteReceived", ({ fromId, fromName, gameCode }: { fromId: number; fromName: string; gameCode: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "gameInvite", fromId, fromName, gameCode, createdAt: Date.now(), read: false },
...prev
]);
});
return () => {
notifSocket.off("friendRequestReceived");
notifSocket.off("friendRequestAccepted");
notifSocket.off("gameInviteReceived");
notifSocket.disconnect();
};
}, [user?.id]);
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
const dismiss = (id: string) => setNotifications((prev) => prev.filter((n) => n.id !== id));
const sendGameInvite = (toId: number, gameCode: string) => {
if (notifSocket.connected) {
notifSocket.emit("sendGameInvite", { toId, gameCode });
}
};
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markAllRead, dismiss, sendGameInvite }}>
{children}
</NotificationContext.Provider>
);
}

View File

@@ -222,7 +222,8 @@ export const loginUser = async (req: Request, res: Response) => {
email: users[0].email, email: users[0].email,
wins: users[0].wins, wins: users[0].wins,
losses: users[0].losses, losses: users[0].losses,
draws: users[0].draws draws: users[0].draws,
role: users[0].role
}; };
req.session.save(() => { req.session.save(() => {
res.status(200).json(req.session.user); res.status(200).json(req.session.user);

View File

@@ -1,6 +1,8 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import UserModel from "../db/models/user.model.js"; import UserModel from "../db/models/user.model.js";
import { io } from "../server.js";
import { onlineUsers } from "../socket/state.js";
export const sendRequest = async (req: Request, res: Response) => { export const sendRequest = async (req: Request, res: Response) => {
try { try {
@@ -49,6 +51,15 @@ export const sendRequest = async (req: Request, res: Response) => {
[fromId, toId] [fromId, toId]
); );
// Notify recipient if online
const fromUser = req.session.user!;
if (onlineUsers.has(toId)) {
io.to(`user:${toId}`).emit("friendRequestReceived", {
fromId: fromUser.id,
fromName: fromUser.name
});
}
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name }); res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -124,6 +135,13 @@ export const respondToRequest = async (req: Request, res: Response) => {
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
[a, b] [a, b]
); );
// Notify original sender that request was accepted
if (onlineUsers.has(from_id)) {
io.to(`user:${from_id}`).emit("friendRequestAccepted", {
fromId: userId,
fromName: req.session.user!.name
});
}
} }
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." }); res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });

View File

@@ -11,6 +11,7 @@ import {
leaveLobby, leaveLobby,
sendMove sendMove
} from "./game.socket.js"; } from "./game.socket.js";
import { onlineUsers } from "./state.js";
const socketConnect = (socket: Socket) => { const socketConnect = (socket: Socket) => {
const req = socket.request; const req = socket.request;
@@ -25,7 +26,28 @@ const socketConnect = (socket: Socket) => {
}); });
}); });
socket.on("disconnect", leaveLobby); // Track online users and join personal notification room
const userId = req.session.user?.id;
if (userId && typeof userId === "number") {
onlineUsers.set(userId, socket.id);
socket.join(`user:${userId}`);
}
socket.on("disconnect", () => {
if (userId && typeof userId === "number") onlineUsers.delete(userId);
leaveLobby.call(socket);
});
// Forward game invites to target user's personal room
socket.on("sendGameInvite", ({ toId, gameCode }: { toId: number; gameCode: string }) => {
const fromUser = req.session.user;
if (!fromUser?.id || typeof fromUser.id === "string") return;
io.to(`user:${toId}`).emit("gameInviteReceived", {
fromId: fromUser.id,
fromName: fromUser.name,
gameCode
});
});
socket.on("joinLobby", joinLobby); socket.on("joinLobby", joinLobby);
socket.on("leaveLobby", leaveLobby); socket.on("leaveLobby", leaveLobby);

View File

@@ -0,0 +1,2 @@
// Shared socket state — imported by socket/index.ts and controllers to avoid circular deps
export const onlineUsers = new Map<number, string>();

12
types/index.d.ts vendored
View File

@@ -93,6 +93,18 @@ export interface TournamentPlayer {
gamesPlayed?: number; gamesPlayed?: number;
} }
export type NotificationType = "friendRequest" | "friendAccepted" | "gameInvite";
export interface AppNotification {
id: string;
type: NotificationType;
fromId: number;
fromName: string;
gameCode?: string;
createdAt: number;
read: boolean;
}
export interface TournamentRound { export interface TournamentRound {
id?: number; id?: number;
tournamentId?: number; tournamentId?: number;