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

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

View File

@@ -1,6 +1,8 @@
import type { Request, Response } from "express";
import { db } from "../db/index.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) => {
try {
@@ -49,6 +51,15 @@ export const sendRequest = async (req: Request, res: Response) => {
[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 });
} catch (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`,
[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." });

View File

@@ -11,6 +11,7 @@ import {
leaveLobby,
sendMove
} from "./game.socket.js";
import { onlineUsers } from "./state.js";
const socketConnect = (socket: Socket) => {
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("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>();