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:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
80
client/src/components/NotificationPanel.tsx
Normal file
80
client/src/components/NotificationPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
client/src/context/NotificationContext.tsx
Normal file
87
client/src/context/NotificationContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
2
server/src/socket/state.ts
Normal file
2
server/src/socket/state.ts
Normal 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
12
types/index.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user