From eb4efc131e92134c97aa729d0d422668bd8acbbb Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:11:45 +0100 Subject: [PATCH 1/4] fix: make appointment redirection --- .../auth/signInConfirmation/SignInConfirmation.tsx | 14 ++++++++++++-- client/src/components/modalHost/modalHost.tsx | 8 +++++++- .../teacherSchedule/TeacherSchedule.tsx | 8 +++++--- .../src/components/ui/button/GoogleAuthButton.tsx | 10 +++++++++- .../features/auth/mutations/useGoogleMutation.ts | 4 +++- .../features/auth/mutations/useLoginMutation.ts | 4 ++-- client/src/pages/loginPage/LoginPage.tsx | 5 ++++- client/src/router/authRedirect.ts | 12 ++++++++++++ client/src/store/modals.store.ts | 2 +- server/src/controllers/auth.controller.ts | 8 ++++---- 10 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 client/src/router/authRedirect.ts diff --git a/client/src/components/auth/signInConfirmation/SignInConfirmation.tsx b/client/src/components/auth/signInConfirmation/SignInConfirmation.tsx index 3b7d1151..338e81aa 100644 --- a/client/src/components/auth/signInConfirmation/SignInConfirmation.tsx +++ b/client/src/components/auth/signInConfirmation/SignInConfirmation.tsx @@ -7,14 +7,24 @@ import Cross from "../../icons/Cross"; interface Props { isOpen: boolean; onClose: () => void; + returnTo: string | undefined; } -export const SignInConfirmation: React.FC = ({ isOpen, onClose }) => { +export const SignInConfirmation: React.FC = ({ + isOpen, + onClose, + returnTo, +}) => { const navigate = useNavigate(); const handleSignIn = () => { + const back = returnTo ?? location.pathname + location.search; + navigate(authRoutesVariables.loginStudent, { + replace: true, + state: { returnTo: back }, + }); + onClose(); - navigate(authRoutesVariables.loginStudent); }; if (!isOpen) return null; diff --git a/client/src/components/modalHost/modalHost.tsx b/client/src/components/modalHost/modalHost.tsx index ccd14885..75793b27 100644 --- a/client/src/components/modalHost/modalHost.tsx +++ b/client/src/components/modalHost/modalHost.tsx @@ -61,7 +61,13 @@ export const ModalHost = () => { )} {activeModal === "signIn" && ( - + )} {activeModal === "fullScreenLoader" && ( diff --git a/client/src/components/teacherSection/teacherSchedule/TeacherSchedule.tsx b/client/src/components/teacherSection/teacherSchedule/TeacherSchedule.tsx index edc5b854..817025ec 100644 --- a/client/src/components/teacherSection/teacherSchedule/TeacherSchedule.tsx +++ b/client/src/components/teacherSection/teacherSchedule/TeacherSchedule.tsx @@ -8,6 +8,7 @@ import { useTeacherAppointmentsQuery } from "../../../features/appointments/quer import { SelectComponent } from "../../ui/select/Select.tsx"; import { Button } from "../../ui/button/Button"; import { getDescriptionValidation } from "../../../utils/appointmentDescription.validation"; +import { useLocation } from "react-router-dom"; interface TeacherScheduleProps { teacher?: TeacherType; @@ -25,11 +26,11 @@ export default function TeacherSchedule({ teacher }: TeacherScheduleProps) { const { open: openModal } = useModalStore(); const user = useAuthSessionStore((state) => state.user); - + const location = useLocation(); const isOwnProfile = user?.id === teacher?.id; const isAuthenticated = !!user; const isTeacher = user?.role === "teacher"; - + const returnTo = location.pathname + location.search; const { data } = useTeacherAppointmentsQuery( isAuthenticated ? teacher?.id : undefined, ); @@ -102,7 +103,8 @@ export default function TeacherSchedule({ teacher }: TeacherScheduleProps) { const handleTimeSelection = (time: string): void => { if (!isAuthenticated) { - openModal("signIn"); + openModal("signIn", { returnTo }); + return; } diff --git a/client/src/components/ui/button/GoogleAuthButton.tsx b/client/src/components/ui/button/GoogleAuthButton.tsx index c6e130f2..baf3f146 100644 --- a/client/src/components/ui/button/GoogleAuthButton.tsx +++ b/client/src/components/ui/button/GoogleAuthButton.tsx @@ -1,6 +1,7 @@ import { GoogleLogin } from "@react-oauth/google"; import { Intent, Role } from "../../../api/auth/types"; import { useGoogleLoginMutation } from "../../../features/auth/mutations/useGoogleMutation"; +import { useLocation } from "react-router-dom"; type Props = { role: Role; @@ -15,7 +16,14 @@ export const GoogleAuthButton = ({ onClose, className, }: Props) => { - const { mutate } = useGoogleLoginMutation({ role, intent, onClose }); + const location = useLocation(); + const returnTo = (location.state as { returnTo?: string } | null)?.returnTo; + const { mutate } = useGoogleLoginMutation({ + role, + intent, + onClose, + returnTo, + }); return (
diff --git a/client/src/features/auth/mutations/useGoogleMutation.ts b/client/src/features/auth/mutations/useGoogleMutation.ts index f257c805..af26fd2e 100644 --- a/client/src/features/auth/mutations/useGoogleMutation.ts +++ b/client/src/features/auth/mutations/useGoogleMutation.ts @@ -13,12 +13,14 @@ type useLoginMutationProps = { onClose?: () => void; intent: Intent; role: Role; + returnTo?: string; }; export function useGoogleLoginMutation({ intent, role, onClose, + returnTo, }: useLoginMutationProps) { const setAccessToken = useAuthSessionStore((s) => s.setAccessToken); const notifyError = useNotificationStore((s) => s.error); @@ -32,7 +34,7 @@ export function useGoogleLoginMutation({ onSuccess: ({ accessToken }) => { setAccessToken(accessToken); localStorage.setItem("hadSession", "1"); - navigate("/", { replace: true }); + navigate(returnTo ?? "/", { replace: true }); onClose?.(); }, onError: (error) => { diff --git a/client/src/features/auth/mutations/useLoginMutation.ts b/client/src/features/auth/mutations/useLoginMutation.ts index 6b29daba..25a4ea44 100644 --- a/client/src/features/auth/mutations/useLoginMutation.ts +++ b/client/src/features/auth/mutations/useLoginMutation.ts @@ -7,7 +7,7 @@ import { getErrorMessage } from "../../../util/ErrorUtil"; import { LoginFinalType } from "../../../api/auth/types"; import { useNotificationStore } from "../../../store/notification.store"; -export function useLoginMutation() { +export function useLoginMutation(returnTo?: string) { const qc = useQueryClient(); const navigate = useNavigate(); const setAccessToken = useAuthSessionStore((s) => s.setAccessToken); @@ -20,7 +20,7 @@ export function useLoginMutation() { setAccessToken(accessToken); success("Successfully logged in"); localStorage.setItem("hadSession", "1"); - navigate("/", { replace: true }); + navigate(returnTo ?? "/", { replace: true }); await qc.invalidateQueries({ queryKey: queryKeys.me }); }, onError: (error) => { diff --git a/client/src/pages/loginPage/LoginPage.tsx b/client/src/pages/loginPage/LoginPage.tsx index f5f737a3..bd6abbe6 100644 --- a/client/src/pages/loginPage/LoginPage.tsx +++ b/client/src/pages/loginPage/LoginPage.tsx @@ -1,9 +1,12 @@ import { LoginFinalType, Role } from "../../api/auth/types"; import { LoginForm } from "../../components/auth/loginForm/LoginForm"; import { useLoginMutation } from "../../features/auth/mutations/useLoginMutation"; +import { useLocation } from "react-router-dom"; export const LoginPage = ({ role }: { role: Role }) => { - const { mutateAsync, isPending } = useLoginMutation(); + const location = useLocation(); + const returnTo = (location.state as { returnTo?: string } | null)?.returnTo; + const { mutateAsync, isPending } = useLoginMutation(returnTo); const onSubmit = async (data: LoginFinalType) => { await mutateAsync({ ...data, role }); diff --git a/client/src/router/authRedirect.ts b/client/src/router/authRedirect.ts new file mode 100644 index 00000000..63fb61c5 --- /dev/null +++ b/client/src/router/authRedirect.ts @@ -0,0 +1,12 @@ +import type { NavigateFunction, Location } from "react-router-dom"; + +export function goToLogin(navigate: NavigateFunction, location: Location) { + const returnTo = location.pathname + location.search; + + const to = "/auth/login-student"; + + navigate(to, { + replace: true, + state: { returnTo }, + }); +} diff --git a/client/src/store/modals.store.ts b/client/src/store/modals.store.ts index 6a091ce3..a7672f78 100644 --- a/client/src/store/modals.store.ts +++ b/client/src/store/modals.store.ts @@ -24,7 +24,7 @@ type ModalPayload = { description?: string; onSuccess?: () => void; }; - signIn?: never; + signIn?: { returnTo?: string }; confirmDelete?: { title: string; message: string; diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 7d18d202..04f239a6 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -93,7 +93,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: false, path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", @@ -142,7 +142,7 @@ export class AuthController { res.cookie("refreshToken", newRefreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: false, path: "/", maxAge: 2 * 60 * 60 * 1000, }); @@ -278,7 +278,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: false, path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", @@ -312,7 +312,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: false, path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", From 88ea61824d7ede595a2e7de43fe268fe3abb8b73 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:05:20 +0100 Subject: [PATCH 2/4] feat: realtime without nptifications DB --- .../DropdownNotificationsMenu.tsx | 160 ++++++++++++++++++ .../controlPanel/ControlPanelTrigger.tsx | 4 +- .../controlPanel/IndicatorTrigger.tsx | 87 +++++++--- client/src/components/icons/Bell.tsx | 32 ++++ client/src/components/ui/badge/Badge.tsx | 15 ++ client/src/hooks/useNotificationsRealtime.ts | 46 +++++ client/src/layouts/RootLayout.tsx | 3 +- client/src/store/notificationFeed.store.ts | 87 ++++++++++ client/src/styles/theme.css | 1 + client/src/types/notificationFeed.types.ts | 22 +++ .../src/controllers/appointment.controller.ts | 31 ++++ server/src/services/chat/chat.service.ts | 21 +++ server/src/socket/socket.server.ts | 18 +- 13 files changed, 495 insertions(+), 32 deletions(-) create mode 100644 client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx create mode 100644 client/src/components/icons/Bell.tsx create mode 100644 client/src/components/ui/badge/Badge.tsx create mode 100644 client/src/hooks/useNotificationsRealtime.ts create mode 100644 client/src/store/notificationFeed.store.ts create mode 100644 client/src/types/notificationFeed.types.ts diff --git a/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx new file mode 100644 index 00000000..9f362c17 --- /dev/null +++ b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef } from "react"; +import { NavLink } from "react-router-dom"; +import { twMerge } from "tailwind-merge"; +import { Button } from "../ui/button/Button"; +import { AppNotification } from "../../store/notificationFeed.store.ts"; +import { + chatRoutes, + studentBase, + studentPrivatesRoutesVariables, + teacherBase, + teacherPrivatesRoutesVariables, +} from "../../router/routesVariables/pathVariables.ts"; +import { Badge } from "../ui/badge/Badge.tsx"; + +type ControlPanelTypes = { + classNames?: string; + options: AppNotification[]; + openMenu: boolean; + setOpenMenu: (open: boolean) => void; + currentRole?: "student" | "teacher" | "moderator"; +}; + +export const DropdownNotificationsMenu = ({ + classNames, + options, + openMenu, + setOpenMenu, + currentRole, +}: ControlPanelTypes) => { + const wrapperRef = useRef(null); + + const getNotificationLink = (option: AppNotification) => { + if (option.type === "chatMessages") { + return currentRole === "student" + ? `${studentBase}/${chatRoutes.root}/${option.conversationId}` + : `${teacherBase}/${chatRoutes.root}/${option.conversationId}`; + } + + return currentRole === "student" + ? `${studentBase}/${studentPrivatesRoutesVariables.appointments}` + : `${teacherBase}/${teacherPrivatesRoutesVariables.appointments}`; + }; + + const getNotificationItemClassName = (option: AppNotification) => { + if (option.type === "chatMessages") { + return "bg-blue-500/10 hover:bg-blue-500/15"; + } + + if (option.status === "approved") { + return "bg-green-500/10 hover:bg-green-500/15"; + } + + return "bg-red-500/10 hover:bg-red-500/15"; + }; + + useEffect(() => { + if (!openMenu) { + return; + } + + const onPointerDown = (e: PointerEvent) => { + const el = wrapperRef.current; + if (!el) { + return; + } + if (!el.contains(e.target as Node)) { + setOpenMenu(false); + } + }; + + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [openMenu]); + + return ( +
+
+
+ {options.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + options.map((option) => + option.type === "chatMessages" ? ( + + ) : ( + + ), + ) + )} +
+
+
+ ); +}; diff --git a/client/src/components/controlPanel/ControlPanelTrigger.tsx b/client/src/components/controlPanel/ControlPanelTrigger.tsx index d035d5d9..427b5be9 100644 --- a/client/src/components/controlPanel/ControlPanelTrigger.tsx +++ b/client/src/components/controlPanel/ControlPanelTrigger.tsx @@ -1,7 +1,7 @@ import { DropdownMenu } from "./DropdownMenu.tsx"; import { Button } from "../ui/button/Button.tsx"; import ArrowDown from "../icons/ArrowDown.tsx"; -import { useState } from "react"; +import React, { useState } from "react"; import { LinkOption } from "../../types/linkOptionsType.ts"; type ControlPanelTriggerProps = { @@ -24,7 +24,7 @@ export const ControlPanelTrigger = ({ options }: ControlPanelTriggerProps) => { className="items-center gap-2.5" onClick={() => setOpenMenu((prev) => !prev)} > - Sign in + Sign in diff --git a/client/src/components/controlPanel/IndicatorTrigger.tsx b/client/src/components/controlPanel/IndicatorTrigger.tsx index fd7d396e..563ce9cb 100644 --- a/client/src/components/controlPanel/IndicatorTrigger.tsx +++ b/client/src/components/controlPanel/IndicatorTrigger.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useState } from "react"; import { DropdownMenu } from "./DropdownMenu"; import { Button } from "../ui/button/Button"; import ArrowDown from "../icons/ArrowDown"; @@ -6,6 +6,10 @@ import DefaultAvatarIcon from "../icons/DefaultAvatarIcon"; import { useAuthSessionStore } from "../../store/authSession.store"; import { getAvatarUrl } from "../../api/upload/upload.api"; import type { LinkOption } from "../../types/linkOptionsType"; +import Bell from "../icons/Bell.tsx"; +import { useNotificationFeedStore } from "../../store/notificationFeed.store.ts"; +import { DropdownNotificationsMenu } from "../DropdownNotificationsMenu/DropdownNotificationsMenu.tsx"; +import { twMerge } from "tailwind-merge"; type Props = { options: LinkOption[]; @@ -14,46 +18,73 @@ type Props = { export const IndicatorTrigger = ({ options, variant = "private" }: Props) => { const [openMenu, setOpenMenu] = useState(false); + const [openNotificationMenu, setOpenNotificationMenu] = useState(false); const user = useAuthSessionStore((s) => s.user); - + const notifications = useNotificationFeedStore((s) => s.items); const avatarUrl = getAvatarUrl(user?.profileImageUrl || null); const wrapperClass = variant === "private" ? "hidden md:flex items-center" : "flex items-center"; - + const unreadNotifications = useNotificationFeedStore( + (s) => s.items.filter((item) => !item.isRead).length, + ); return ( -
+
+
+ + +
+
- + + +
); diff --git a/client/src/components/icons/Bell.tsx b/client/src/components/icons/Bell.tsx new file mode 100644 index 00000000..db9dd065 --- /dev/null +++ b/client/src/components/icons/Bell.tsx @@ -0,0 +1,32 @@ +import { + type Ref, + type SVGProps, + forwardRef, + memo, + type MemoExoticComponent, + type ForwardRefExoticComponent, +} from "react"; +const SvgComponent = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); +const SvgIcon = memo(forwardRef(SvgComponent)) as MemoExoticComponent< + ForwardRefExoticComponent> +>; + +export default SvgIcon; diff --git a/client/src/components/ui/badge/Badge.tsx b/client/src/components/ui/badge/Badge.tsx new file mode 100644 index 00000000..d1177106 --- /dev/null +++ b/client/src/components/ui/badge/Badge.tsx @@ -0,0 +1,15 @@ +type BadgeType = { + title: string; +}; + +export const Badge = ({ title }: BadgeType) => { + return ( +
+ {title} +
+ ); +}; diff --git a/client/src/hooks/useNotificationsRealtime.ts b/client/src/hooks/useNotificationsRealtime.ts new file mode 100644 index 00000000..e8a89a8c --- /dev/null +++ b/client/src/hooks/useNotificationsRealtime.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; +import { useSocketStore } from "../store/socket.store.ts"; +import { useNotificationFeedStore } from "../store/notificationFeed.store.ts"; +import { AppointmentStatus } from "../types/appointments.types.ts"; + +type AppNotification = + | { + type: "chatMessages"; + conversationId: string; + message: { + id: string; + text: string; + senderId: string; + createdAt: string; + }; + } + | { + type: "appointmentStatus"; + appointmentId: string; + status: AppointmentStatus; + teacherId: string; + lesson: string; + date: string; + time: string; + }; + +export const useNotificationsRealtime = () => { + const socket = useSocketStore((s) => s.socket); + const addNotification = useNotificationFeedStore((s) => s.addNotification); + + useEffect(() => { + if (!socket) { + return; + } + + const onNewNotification = (payload: AppNotification) => { + addNotification(payload); + }; + + socket.on("notification:new", onNewNotification); + + return () => { + socket.off("notification:new", onNewNotification); + }; + }, [socket, addNotification]); +}; diff --git a/client/src/layouts/RootLayout.tsx b/client/src/layouts/RootLayout.tsx index af152ef1..a93d6d88 100644 --- a/client/src/layouts/RootLayout.tsx +++ b/client/src/layouts/RootLayout.tsx @@ -16,6 +16,7 @@ import type { AxiosError } from "axios"; import { useAudioUnlock } from "../hooks/useAudioUnlock.ts"; import { useUnreadChatSync } from "../hooks/useUnreadChatSync.tsx"; import { useSocketConnection } from "../hooks/useSocketConnection.ts"; +import { useNotificationsRealtime } from "../hooks/useNotificationsRealtime.ts"; type IncomingCallSignal = VideoCallResponse & { callId?: string }; export const RootLayout = () => { @@ -43,7 +44,7 @@ export const RootLayout = () => { useUnreadChatSync(); useSocketConnection(); - + useNotificationsRealtime(); useEffect(() => { // Student listens for incoming call event. if (!isStudent || !socket) return; diff --git a/client/src/store/notificationFeed.store.ts b/client/src/store/notificationFeed.store.ts new file mode 100644 index 00000000..c9259788 --- /dev/null +++ b/client/src/store/notificationFeed.store.ts @@ -0,0 +1,87 @@ +import { create } from "zustand"; + +export type NotificationPerson = { + id: string; + name: string; + imageUrl: string | null; +}; + +export type AppNotification = + | { + id: string; + type: "chatMessages"; + conversationId: string; + sender: NotificationPerson; + message: { + id: string; + text: string; + senderId: string; + createdAt: string; + }; + createdAt: string; + isRead: boolean; + } + | { + id: string; + type: "appointmentStatus"; + appointmentId: string; + status: "approved" | "rejected"; + actor: NotificationPerson; + lesson: string; + date: string; + time: string; + createdAt: string; + isRead: boolean; + }; + +export type NewAppNotification = + | { + type: "chatMessages"; + conversationId: string; + sender: NotificationPerson; + message: { + id: string; + text: string; + senderId: string; + createdAt: string; + }; + } + | { + type: "appointmentStatus"; + appointmentId: string; + status: "approved" | "rejected"; + actor: NotificationPerson; + lesson: string; + date: string; + time: string; + }; + +type NotificationFeedState = { + items: AppNotification[]; + addNotification: (notification: NewAppNotification) => void; + markAllAsRead: () => void; +}; + +export const useNotificationFeedStore = create( + (set) => ({ + items: [], + + addNotification: (notification) => + set((state) => ({ + items: [ + { + ...notification, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + isRead: false, + } as AppNotification, + ...state.items, + ], + })), + + markAllAsRead: () => + set((state) => ({ + items: state.items.map((item) => ({ ...item, isRead: true })), + })), + }), +); diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index de9fa22c..7e261d7b 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -42,4 +42,5 @@ --color-gray-950: #030712; --color-danger: #f7374f; + --color-danger-100: #c50226; } diff --git a/client/src/types/notificationFeed.types.ts b/client/src/types/notificationFeed.types.ts new file mode 100644 index 00000000..ff1a973b --- /dev/null +++ b/client/src/types/notificationFeed.types.ts @@ -0,0 +1,22 @@ +import { AppointmentStatus } from "./appointments.types.ts"; + +export type AppNotification = + | { + type: "chatMessages"; + conversationId: string; + message: { + id: string; + text: string; + senderId: string; + createdAt: string; + }; + } + | { + type: "appointmentStatus"; + appointmentId: string; + status: AppointmentStatus; + teacherId: string; + lesson: string; + date: string; + time: string; + }; diff --git a/server/src/controllers/appointment.controller.ts b/server/src/controllers/appointment.controller.ts index 98c0ea79..18e2b699 100644 --- a/server/src/controllers/appointment.controller.ts +++ b/server/src/controllers/appointment.controller.ts @@ -18,12 +18,15 @@ import { validatePaginationParams, validateAuthorization, } from "../utils/validation/requestValidation.util.js"; +import { getIO } from "../socket/io.holder.js"; +import { TeacherQuery } from "../repositories/queryRepositories/teacher.query.js"; @injectable() export class AppointmentController { constructor( @inject(TYPES.AppointmentService) protected appointmentService: AppointmentService, + @inject(TYPES.TeacherQuery) protected teacherQuery: TeacherQuery, ) {} async createAppointmentController( @@ -127,6 +130,7 @@ export class AppointmentController { res: Response, next: NextFunction, ) { + const io = getIO(); try { const appointment = await this.appointmentService.updateAppointmentStatus( req.params.id, @@ -137,6 +141,33 @@ export class AppointmentController { return res.status(404).json({ message: "Appointment not found" }); } + const teacher = await this.teacherQuery.getTeacherById( + appointment.teacherId, + ); + + if ( + appointment.status === "approved" || + appointment.status === "rejected" + ) { + io?.to(`user:${appointment.studentId}`).emit("notification:new", { + id: crypto.randomUUID(), + type: "appointmentStatus", + appointmentId: appointment.id, + status: appointment.status, + lesson: appointment.lesson, + date: appointment.date, + time: appointment.time, + createdAt: new Date().toISOString(), + isRead: false, + actor: { + id: appointment.teacherId, + name: teacher + ? `${teacher.firstName ?? ""} ${teacher.lastName ?? ""}`.trim() + : "Unknown teacher", + imageUrl: teacher?.profileImageUrl ?? null, + }, + }); + } return res.status(200).json(appointment); } catch (error) { return next(error); diff --git a/server/src/services/chat/chat.service.ts b/server/src/services/chat/chat.service.ts index dab12778..b6708d98 100644 --- a/server/src/services/chat/chat.service.ts +++ b/server/src/services/chat/chat.service.ts @@ -37,6 +37,7 @@ export class ChatService { async sendMessage(args: { conversationId: string; senderId: string; + senderRole: "student" | "teacher"; text: string; }) { const can = await this.canAccessConversation( @@ -61,10 +62,30 @@ export class ChatService { createdAt: new Date(message.createdAt), }); + let senderProfile: { + id: string; + firstName?: string; + lastName?: string; + profileImageUrl?: string | null; + } | null = null; + + if (args.senderRole === "student") { + senderProfile = await this.studentQuery.getStudentById(args.senderId); + } else { + senderProfile = await this.teacherQuery.getTeacherById(args.senderId); + } + return { message, recipientId: conversationUpdate.recipientId, unreadCount: conversationUpdate.unreadCount, + sender: { + id: args.senderId, + name: senderProfile + ? `${senderProfile.firstName ?? ""} ${senderProfile.lastName ?? ""}`.trim() + : "Unknown user", + imageUrl: senderProfile?.profileImageUrl ?? null, + }, }; } diff --git a/server/src/socket/socket.server.ts b/server/src/socket/socket.server.ts index e5607793..2a998a71 100644 --- a/server/src/socket/socket.server.ts +++ b/server/src/socket/socket.server.ts @@ -184,11 +184,12 @@ function registerChatHandlers(_io: Server, socket: Socket) { } try { - const { userId } = socket.data as SocketData; + const { userId, role } = socket.data as SocketData; const result = await chatService.sendMessage({ conversationId: payload.conversationId, senderId: userId, + senderRole: role, text: payload.text, }); @@ -201,6 +202,21 @@ function registerChatHandlers(_io: Server, socket: Socket) { unreadCount: result.unreadCount, }); + _io.to(`user:${result.recipientId}`).emit("notification:new", { + id: crypto.randomUUID(), + type: "chatMessages", + conversationId: payload.conversationId, + createdAt: new Date().toISOString(), + isRead: false, + message: { + id: result.message.id, + text: result.message.text, + senderId: result.message.senderId, + createdAt: result.message.createdAt, + }, + sender: result.sender, + }); + cb?.({ ok: true, message: result.message }); } catch (e: unknown) { cb?.({ ok: false, error: errorToMessage(e) }); From c66887d2f31f9d708f50c2719bc1e20aa713a142 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:52:25 +0100 Subject: [PATCH 3/4] feat: notification flow --- .../api/notifications/notifications.api.ts | 18 ++ .../DropdownNotificationsMenu.tsx | 168 ++++++++++++------ .../controlPanel/IndicatorTrigger.tsx | 47 +++-- .../src/components/headerPrivate/TopBar.tsx | 43 ++--- .../notificationBar/NotificationBar.tsx | 47 +++++ .../mutation/useDeleteAllReadNotifications.ts | 27 +++ .../mutation/useMarkOneNotificationAsRead.tsx | 30 ++++ .../mutation/useMutationsNotifications.tsx | 29 +++ .../query/useQueryNotifications.ts | 29 +++ client/src/features/queryKeys.ts | 4 + client/src/hooks/useHydrateNotifications.tsx | 15 ++ client/src/hooks/useNotificationsRealtime.ts | 27 +-- client/src/layouts/RootLayout.tsx | 4 +- client/src/store/notificationFeed.store.ts | 44 ++--- server/src/app.ts | 2 + server/src/composition/composition.types.ts | 5 + server/src/composition/compositionRoot.ts | 17 ++ .../src/controllers/appointment.controller.ts | 39 ++-- .../controllers/notification.controller.ts | 58 ++++++ server/src/db/schemes/notification.schema.ts | 53 ++++++ .../notifications.command.ts | 41 +++++ .../queryRepositories/notifications.query.ts | 12 ++ server/src/routes/notificationsRoute.ts | 35 ++++ .../notifications/notifications.service.ts | 35 ++++ server/src/socket/socket.server.ts | 32 +++- .../notifications/notifications.types.ts | 31 ++++ 26 files changed, 712 insertions(+), 180 deletions(-) create mode 100644 client/src/api/notifications/notifications.api.ts create mode 100644 client/src/components/notificationBar/NotificationBar.tsx create mode 100644 client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts create mode 100644 client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx create mode 100644 client/src/features/notifications/mutation/useMutationsNotifications.tsx create mode 100644 client/src/features/notifications/query/useQueryNotifications.ts create mode 100644 client/src/hooks/useHydrateNotifications.tsx create mode 100644 server/src/controllers/notification.controller.ts create mode 100644 server/src/db/schemes/notification.schema.ts create mode 100644 server/src/repositories/commandRepositories/notifications.command.ts create mode 100644 server/src/repositories/queryRepositories/notifications.query.ts create mode 100644 server/src/routes/notificationsRoute.ts create mode 100644 server/src/services/notifications/notifications.service.ts create mode 100644 server/src/types/notifications/notifications.types.ts diff --git a/client/src/api/notifications/notifications.api.ts b/client/src/api/notifications/notifications.api.ts new file mode 100644 index 00000000..cd4a03e5 --- /dev/null +++ b/client/src/api/notifications/notifications.api.ts @@ -0,0 +1,18 @@ +import { apiProtected } from "../api.ts"; + +export const getMyNotifications = async () => { + const response = await apiProtected.get("/api/notifications"); + return response.data; +}; + +export const markAllNotificationsAsRead = async () => { + await apiProtected.patch("/api/notifications/read-all"); +}; + +export const markNotificationAsRead = async (id: string) => { + await apiProtected.patch(`/api/notifications/${id}/read`); +}; + +export const deleteAllReadNotifications = async () => { + await apiProtected.delete("/api/notifications/read-all"); +}; diff --git a/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx index 9f362c17..7a7f0739 100644 --- a/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx +++ b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx @@ -11,7 +11,10 @@ import { teacherPrivatesRoutesVariables, } from "../../router/routesVariables/pathVariables.ts"; import { Badge } from "../ui/badge/Badge.tsx"; - +import { useMarkOneNotificationAsRead } from "../../features/notifications/mutation/useMarkOneNotificationAsRead.tsx"; +import { useDeleteAllReadNotifications } from "../../features/notifications/mutation/useDeleteAllReadNotifications.ts"; +import { unlockScroll } from "../../util/modalScroll.util.ts"; +import { AnimatePresence, motion } from "framer-motion"; type ControlPanelTypes = { classNames?: string; options: AppNotification[]; @@ -28,7 +31,11 @@ export const DropdownNotificationsMenu = ({ currentRole, }: ControlPanelTypes) => { const wrapperRef = useRef(null); + const { mutateAsync: markOneAsRead } = useMarkOneNotificationAsRead(); + const { mutateAsync: clearReadNotifications, isPending } = + useDeleteAllReadNotifications(); + const hasReadNotifications = options.some((item) => item.isRead); const getNotificationLink = (option: AppNotification) => { if (option.type === "chatMessages") { return currentRole === "student" @@ -41,7 +48,16 @@ export const DropdownNotificationsMenu = ({ : `${teacherBase}/${teacherPrivatesRoutesVariables.appointments}`; }; + const onCloseNotificationsMenu = () => { + unlockScroll(); + setOpenMenu(false); + }; + const getNotificationItemClassName = (option: AppNotification) => { + if (option.isRead) { + return "bg-zinc-500/10 hover:bg-zinc-500/15"; + } + if (option.type === "chatMessages") { return "bg-blue-500/10 hover:bg-blue-500/15"; } @@ -64,7 +80,7 @@ export const DropdownNotificationsMenu = ({ return; } if (!el.contains(e.target as Node)) { - setOpenMenu(false); + onCloseNotificationsMenu(); } }; @@ -86,72 +102,110 @@ export const DropdownNotificationsMenu = ({ : "invisible -translate-y-2 opacity-0 pointer-events-none", )} > -
+
+ + Notifications + + + {hasReadNotifications && ( + + )} +
+
{options.length === 0 ? (
No notifications yet
) : ( - options.map((option) => - option.type === "chatMessages" ? ( -
- - ) : ( - + ) : ( +
- - ), - ) + + )} + + ))} + )}
diff --git a/client/src/components/controlPanel/IndicatorTrigger.tsx b/client/src/components/controlPanel/IndicatorTrigger.tsx index 563ce9cb..663ac557 100644 --- a/client/src/components/controlPanel/IndicatorTrigger.tsx +++ b/client/src/components/controlPanel/IndicatorTrigger.tsx @@ -6,10 +6,10 @@ import DefaultAvatarIcon from "../icons/DefaultAvatarIcon"; import { useAuthSessionStore } from "../../store/authSession.store"; import { getAvatarUrl } from "../../api/upload/upload.api"; import type { LinkOption } from "../../types/linkOptionsType"; -import Bell from "../icons/Bell.tsx"; import { useNotificationFeedStore } from "../../store/notificationFeed.store.ts"; -import { DropdownNotificationsMenu } from "../DropdownNotificationsMenu/DropdownNotificationsMenu.tsx"; import { twMerge } from "tailwind-merge"; +import { lockScroll } from "../../util/modalScroll.util.ts"; +import { NotificationBar } from "../notificationBar/NotificationBar.tsx"; type Props = { options: LinkOption[]; @@ -20,37 +20,30 @@ export const IndicatorTrigger = ({ options, variant = "private" }: Props) => { const [openMenu, setOpenMenu] = useState(false); const [openNotificationMenu, setOpenNotificationMenu] = useState(false); const user = useAuthSessionStore((s) => s.user); - const notifications = useNotificationFeedStore((s) => s.items); const avatarUrl = getAvatarUrl(user?.profileImageUrl || null); - - const wrapperClass = - variant === "private" ? "hidden md:flex items-center" : "flex items-center"; + const notifications = useNotificationFeedStore((s) => s.items); const unreadNotifications = useNotificationFeedStore( (s) => s.items.filter((item) => !item.isRead).length, ); + + const onOpenNotificationsMenu = () => { + setOpenNotificationMenu(!openNotificationMenu); + lockScroll(); + }; + + const wrapperClass = + variant === "private" ? "hidden md:flex items-center" : "flex items-center"; + return (
-
- - -
+
{ const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); + const notifications = useNotificationFeedStore((s) => s.items); + const [openNotificationMenu, setOpenNotificationMenu] = useState(false); + const unreadNotifications = useNotificationFeedStore( + (s) => s.items.filter((item) => !item.isRead).length, + ); + const user = useAuthSessionStore((s) => s.user); + + const onOpenNotificationsMenu = () => { + setOpenNotificationMenu(!openNotificationMenu); + lockScroll(); + }; return ( <>
@@ -15,28 +29,17 @@ export const TopBar = () => {
{/* Search */} - {/**/} - {/* */} - {/* */} - {/*
*/}
- +
diff --git a/client/src/components/notificationBar/NotificationBar.tsx b/client/src/components/notificationBar/NotificationBar.tsx new file mode 100644 index 00000000..f583310e --- /dev/null +++ b/client/src/components/notificationBar/NotificationBar.tsx @@ -0,0 +1,47 @@ +import { Button } from "../ui/button/Button.tsx"; +import Bell from "../icons/Bell.tsx"; +import { DropdownNotificationsMenu } from "../DropdownNotificationsMenu/DropdownNotificationsMenu.tsx"; +import React, { Dispatch, SetStateAction } from "react"; +import { AppNotification } from "../../store/notificationFeed.store.ts"; + +type NotificationBarProps = { + onOpenNotificationsMenu: () => void; + unreadNotifications: number; + options: AppNotification[]; + openMenu: boolean; + currentRole?: "student" | "teacher" | "moderator"; + setOpenNotificationMenu: Dispatch>; +}; + +export const NotificationBar = ({ + onOpenNotificationsMenu, + unreadNotifications, + setOpenNotificationMenu, + options, + openMenu, + currentRole, +}: NotificationBarProps) => { + return ( +
+ + +
+ ); +}; diff --git a/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts b/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts new file mode 100644 index 00000000..08a4b48e --- /dev/null +++ b/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteAllReadNotifications } from "../../../api/notifications/notifications.api.ts"; +import { useNotificationFeedStore } from "../../../store/notificationFeed.store.ts"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; +import { notificationKeys } from "../../queryKeys.ts"; + +export const useDeleteAllReadNotifications = () => { + const queryClient = useQueryClient(); + const items = useNotificationFeedStore((s) => s.items); + const setItems = useNotificationFeedStore((s) => s.setItems); + const notifyError = useNotificationStore((s) => s.error); + + return useMutation({ + mutationFn: deleteAllReadNotifications, + onSuccess: () => { + const unreadOnly = items.filter((item) => !item.isRead); + + setItems(unreadOnly); + queryClient.setQueryData(notificationKeys.root, unreadOnly); + }, + onError: (error) => { + const msg = getErrorMessage(error); + notifyError(msg ?? "Failed to clear read notifications"); + }, + }); +}; diff --git a/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx b/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx new file mode 100644 index 00000000..46367488 --- /dev/null +++ b/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { markNotificationAsRead } from "../../../api/notifications/notifications.api.ts"; +import { useNotificationFeedStore } from "../../../store/notificationFeed.store.ts"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { notificationKeys } from "../../queryKeys.ts"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; + +export const useMarkOneNotificationAsRead = () => { + const queryClient = useQueryClient(); + const notifyError = useNotificationStore((s) => s.error); + const setItems = useNotificationFeedStore((s) => s.setItems); + const items = useNotificationFeedStore((s) => s.items); + + return useMutation({ + mutationFn: markNotificationAsRead, + onSuccess: (_, notificationId) => { + const updated = items.map((item) => + item.id === notificationId ? { ...item, isRead: true } : item, + ); + + setItems(updated); + + queryClient.setQueryData(notificationKeys.root, updated); + }, + onError: (error) => { + const msg = getErrorMessage(error); + notifyError(msg ?? "Failed to mark notification as read"); + }, + }); +}; diff --git a/client/src/features/notifications/mutation/useMutationsNotifications.tsx b/client/src/features/notifications/mutation/useMutationsNotifications.tsx new file mode 100644 index 00000000..a47f0d58 --- /dev/null +++ b/client/src/features/notifications/mutation/useMutationsNotifications.tsx @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNotificationFeedStore } from "../../../store/notificationFeed.store.ts"; +import { markAllNotificationsAsRead } from "../../../api/notifications/notifications.api.ts"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { notificationKeys } from "../../queryKeys.ts"; + +export const useMarkAllNotificationsAsRead = () => { + const queryClient = useQueryClient(); + const notifyError = useNotificationStore((s) => s.error); + const markAllAsReadLocal = useNotificationFeedStore( + (s) => s.markAllAsReadLocal, + ); + + return useMutation({ + mutationFn: markAllNotificationsAsRead, + onSuccess: async () => { + markAllAsReadLocal(); + + await queryClient.invalidateQueries({ + queryKey: notificationKeys.root, + }); + }, + onError: (error) => { + const msg = getErrorMessage(error); + notifyError(msg ?? "Something went wrong with notifications changes"); + }, + }); +}; diff --git a/client/src/features/notifications/query/useQueryNotifications.ts b/client/src/features/notifications/query/useQueryNotifications.ts new file mode 100644 index 00000000..db5e4919 --- /dev/null +++ b/client/src/features/notifications/query/useQueryNotifications.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { getMyNotifications } from "../../../api/notifications/notifications.api.ts"; +import { useEffect } from "react"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { useAuthSessionStore } from "../../../store/authSession.store.ts"; +import { notificationKeys } from "../../queryKeys.ts"; + +export const useNotificationsQuery = () => { + const notifyError = useNotificationStore((s) => s.error); + const accessToken = useAuthSessionStore((s) => s.accessToken); + const userId = useAuthSessionStore((s) => s.user?.id); + const query = useQuery({ + queryKey: notificationKeys.root, + queryFn: getMyNotifications, + enabled: !!accessToken && !!userId, + retry: false, + refetchOnMount: "always", + }); + + useEffect(() => { + if (query.isError) { + const msg = getErrorMessage(query.error); + notifyError(msg ?? "Failed to load notifications"); + } + }, [query.isError, query.isSuccess, query.error, notifyError]); + + return query; +}; diff --git a/client/src/features/queryKeys.ts b/client/src/features/queryKeys.ts index 6869a6da..309712b8 100644 --- a/client/src/features/queryKeys.ts +++ b/client/src/features/queryKeys.ts @@ -49,3 +49,7 @@ export const chatKeys = { export const subjectsKey = { all: ["subjects"] as const, }; + +export const notificationKeys = { + root: ["notifications"] as const, +}; diff --git a/client/src/hooks/useHydrateNotifications.tsx b/client/src/hooks/useHydrateNotifications.tsx new file mode 100644 index 00000000..73249b03 --- /dev/null +++ b/client/src/hooks/useHydrateNotifications.tsx @@ -0,0 +1,15 @@ +import { useNotificationsQuery } from "../features/notifications/query/useQueryNotifications.ts"; +import { useNotificationFeedStore } from "../store/notificationFeed.store.ts"; +import { useEffect } from "react"; + +export const useHydrateNotifications = () => { + const { data } = useNotificationsQuery(); + const setItems = useNotificationFeedStore((s) => s.setItems); + + useEffect(() => { + if (!data) { + return; + } + setItems(data); + }, [data, setItems]); +}; diff --git a/client/src/hooks/useNotificationsRealtime.ts b/client/src/hooks/useNotificationsRealtime.ts index e8a89a8c..9525fed3 100644 --- a/client/src/hooks/useNotificationsRealtime.ts +++ b/client/src/hooks/useNotificationsRealtime.ts @@ -1,28 +1,9 @@ import { useEffect } from "react"; import { useSocketStore } from "../store/socket.store.ts"; -import { useNotificationFeedStore } from "../store/notificationFeed.store.ts"; -import { AppointmentStatus } from "../types/appointments.types.ts"; - -type AppNotification = - | { - type: "chatMessages"; - conversationId: string; - message: { - id: string; - text: string; - senderId: string; - createdAt: string; - }; - } - | { - type: "appointmentStatus"; - appointmentId: string; - status: AppointmentStatus; - teacherId: string; - lesson: string; - date: string; - time: string; - }; +import { + AppNotification, + useNotificationFeedStore, +} from "../store/notificationFeed.store.ts"; export const useNotificationsRealtime = () => { const socket = useSocketStore((s) => s.socket); diff --git a/client/src/layouts/RootLayout.tsx b/client/src/layouts/RootLayout.tsx index a93d6d88..435efc00 100644 --- a/client/src/layouts/RootLayout.tsx +++ b/client/src/layouts/RootLayout.tsx @@ -17,6 +17,7 @@ import { useAudioUnlock } from "../hooks/useAudioUnlock.ts"; import { useUnreadChatSync } from "../hooks/useUnreadChatSync.tsx"; import { useSocketConnection } from "../hooks/useSocketConnection.ts"; import { useNotificationsRealtime } from "../hooks/useNotificationsRealtime.ts"; +import { useHydrateNotifications } from "../hooks/useHydrateNotifications.tsx"; type IncomingCallSignal = VideoCallResponse & { callId?: string }; export const RootLayout = () => { @@ -39,9 +40,8 @@ export const RootLayout = () => { useAuthInit(); useMeQuery(); useMouseFollowEffect(); - useAudioUnlock(); - + useHydrateNotifications(); useUnreadChatSync(); useSocketConnection(); useNotificationsRealtime(); diff --git a/client/src/store/notificationFeed.store.ts b/client/src/store/notificationFeed.store.ts index c9259788..c67ab409 100644 --- a/client/src/store/notificationFeed.store.ts +++ b/client/src/store/notificationFeed.store.ts @@ -34,52 +34,28 @@ export type AppNotification = isRead: boolean; }; -export type NewAppNotification = - | { - type: "chatMessages"; - conversationId: string; - sender: NotificationPerson; - message: { - id: string; - text: string; - senderId: string; - createdAt: string; - }; - } - | { - type: "appointmentStatus"; - appointmentId: string; - status: "approved" | "rejected"; - actor: NotificationPerson; - lesson: string; - date: string; - time: string; - }; - type NotificationFeedState = { items: AppNotification[]; - addNotification: (notification: NewAppNotification) => void; - markAllAsRead: () => void; + setItems: (items: AppNotification[]) => void; + addNotification: (notification: AppNotification) => void; + markAllAsReadLocal: () => void; }; export const useNotificationFeedStore = create( (set) => ({ items: [], + setItems: (items) => + set({ + items, + }), + addNotification: (notification) => set((state) => ({ - items: [ - { - ...notification, - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - isRead: false, - } as AppNotification, - ...state.items, - ], + items: [notification, ...state.items], })), - markAllAsRead: () => + markAllAsReadLocal: () => set((state) => ({ items: state.items.map((item) => ({ ...item, isRead: true })), })), diff --git a/server/src/app.ts b/server/src/app.ts index 747710cd..844499ca 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -12,6 +12,7 @@ import { videoCallRouter } from "./routes/videoCallRoute.js"; import { subjectRouter } from "./routes/subjectRoute.js"; import { uploadRouter } from "./routes/uploadRoute.js"; import { moderatorRouter } from "./routes/moderatorRoute.js"; +import { notificationsRouter } from "./routes/notificationsRoute.js"; // Create an express server const app = express(); @@ -34,6 +35,7 @@ app.use("/api/chat", chatRouter); app.use("/api/stream", streamRouter); app.use("/api/video-calls", videoCallRouter); app.use("/api/moderator", moderatorRouter); +app.use("/api/notifications", notificationsRouter); app.use("/api/upload", uploadRouter); app.use(globalErrorMiddleware); diff --git a/server/src/composition/composition.types.ts b/server/src/composition/composition.types.ts index 5f296b13..32a9bf9f 100644 --- a/server/src/composition/composition.types.ts +++ b/server/src/composition/composition.types.ts @@ -49,4 +49,9 @@ export const TYPES = { //moderator ModeratorController: Symbol.for("ModeratorController"), ModeratorQuery: Symbol.for("ModeratorQuery"), + //notifications + NotificationCommand: Symbol.for("NotificationCommand"), + NotificationQuery: Symbol.for("NotificationQuery"), + NotificationService: Symbol.for("NotificationService"), + NotificationController: Symbol.for("NotificationController"), }; diff --git a/server/src/composition/compositionRoot.ts b/server/src/composition/compositionRoot.ts index be7f8660..9c937ad1 100644 --- a/server/src/composition/compositionRoot.ts +++ b/server/src/composition/compositionRoot.ts @@ -37,6 +37,10 @@ import { SubjectsController } from "../controllers/subjects.controller.js"; import { SubjectsQuery } from "../repositories/queryRepositories/subjects.query.js"; import { ModeratorController } from "../controllers/moderator.controller.js"; import { ModeratorQuery } from "../repositories/queryRepositories/moderator.query.js"; +import { NotificationCommand } from "../repositories/commandRepositories/notifications.command.js"; +import { NotificationQuery } from "../repositories/queryRepositories/notifications.query.js"; +import { NotificationService } from "../services/notifications/notifications.service.js"; +import { NotificationController } from "../controllers/notification.controller.js"; export const container = new Container(); @@ -111,3 +115,16 @@ container .bind(TYPES.ModeratorController) .to(ModeratorController); container.bind(TYPES.ModeratorQuery).to(ModeratorQuery); +//notifications +container + .bind(TYPES.NotificationCommand) + .to(NotificationCommand); +container + .bind(TYPES.NotificationQuery) + .to(NotificationQuery); +container + .bind(TYPES.NotificationService) + .to(NotificationService); +container + .bind(TYPES.NotificationController) + .to(NotificationController); diff --git a/server/src/controllers/appointment.controller.ts b/server/src/controllers/appointment.controller.ts index 18e2b699..09703cae 100644 --- a/server/src/controllers/appointment.controller.ts +++ b/server/src/controllers/appointment.controller.ts @@ -20,6 +20,7 @@ import { } from "../utils/validation/requestValidation.util.js"; import { getIO } from "../socket/io.holder.js"; import { TeacherQuery } from "../repositories/queryRepositories/teacher.query.js"; +import { NotificationService } from "../services/notifications/notifications.service.js"; @injectable() export class AppointmentController { @@ -27,6 +28,8 @@ export class AppointmentController { @inject(TYPES.AppointmentService) protected appointmentService: AppointmentService, @inject(TYPES.TeacherQuery) protected teacherQuery: TeacherQuery, + @inject(TYPES.NotificationService) + protected notificationService: NotificationService, ) {} async createAppointmentController( @@ -145,29 +148,43 @@ export class AppointmentController { appointment.teacherId, ); + if (!teacher) { + return res.status(404).json({ message: "Teacher not found" }); + } + if ( appointment.status === "approved" || appointment.status === "rejected" ) { - io?.to(`user:${appointment.studentId}`).emit("notification:new", { - id: crypto.randomUUID(), + const teacher = await this.teacherQuery.getTeacherById( + appointment.teacherId, + ); + + if (!teacher) { + return res.status(404).json({ message: "Teacher not found" }); + } + + const notification = await this.notificationService.createNotification({ + userId: appointment.studentId, type: "appointmentStatus", appointmentId: appointment.id, status: appointment.status, + actor: { + id: teacher.id, + name: `${teacher.firstName} ${teacher.lastName}`.trim(), + imageUrl: teacher.profileImageUrl ?? null, + }, lesson: appointment.lesson, date: appointment.date, time: appointment.time, - createdAt: new Date().toISOString(), - isRead: false, - actor: { - id: appointment.teacherId, - name: teacher - ? `${teacher.firstName ?? ""} ${teacher.lastName ?? ""}`.trim() - : "Unknown teacher", - imageUrl: teacher?.profileImageUrl ?? null, - }, }); + + io?.to(`user:${appointment.studentId}`).emit( + "notification:new", + notification, + ); } + return res.status(200).json(appointment); } catch (error) { return next(error); diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 00000000..52cbf9d6 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -0,0 +1,58 @@ +import { inject, injectable } from "inversify"; +import { TYPES } from "../composition/composition.types.js"; +import { NextFunction, Response, Request } from "express"; +import { validateAuthorization } from "../utils/validation/requestValidation.util.js"; +import { RequestWithParams } from "../types/common.types.js"; +import { NotificationService } from "../services/notifications/notifications.service.js"; + +@injectable() +export class NotificationController { + constructor( + @inject(TYPES.NotificationService) + private notificationService: NotificationService, + ) {} + + async getMyNotifications(req: Request, res: Response, next: NextFunction) { + try { + const userId = validateAuthorization(req.auth?.userId); + const items = await this.notificationService.getMyNotifications(userId); + return res.status(200).json(items); + } catch (error) { + return next(error); + } + } + + async markAllAsRead(req: Request, res: Response, next: NextFunction) { + try { + const userId = validateAuthorization(req.auth?.userId); + await this.notificationService.markAllAsRead(userId); + return res.sendStatus(204); + } catch (error) { + return next(error); + } + } + + async markOneAsRead( + req: RequestWithParams<{ id: string }>, + res: Response, + next: NextFunction, + ) { + try { + const userId = validateAuthorization(req.auth?.userId); + await this.notificationService.markOneAsRead(req.params.id, userId); + return res.sendStatus(204); + } catch (error) { + return next(error); + } + } + + async deleteAllRead(req: Request, res: Response, next: NextFunction) { + try { + const userId = validateAuthorization(req.auth?.userId); + await this.notificationService.deleteAllRead(userId); + return res.sendStatus(204); + } catch (error) { + return next(error); + } + } +} diff --git a/server/src/db/schemes/notification.schema.ts b/server/src/db/schemes/notification.schema.ts new file mode 100644 index 00000000..e6e735d7 --- /dev/null +++ b/server/src/db/schemes/notification.schema.ts @@ -0,0 +1,53 @@ +import { Schema, model, type InferSchemaType } from "mongoose"; + +const personSchema = new Schema( + { + id: { type: String, required: true }, + name: { type: String, required: true }, + imageUrl: { type: String, default: null }, + }, + { _id: false }, +); + +const messageSchema = new Schema( + { + id: { type: String, required: true }, + text: { type: String, required: true }, + senderId: { type: String, required: true }, + createdAt: { type: String, required: true }, + }, + { _id: false }, +); + +export const NotificationSchema = new Schema( + { + id: { type: String, required: true, unique: true }, + userId: { type: String, required: true, index: true }, + + type: { + type: String, + enum: ["chatMessages", "appointmentStatus"], + required: true, + }, + + isRead: { type: Boolean, required: true, default: false }, + createdAt: { type: String, required: true }, + + conversationId: { type: String }, + sender: { type: personSchema }, + message: { type: messageSchema }, + + appointmentId: { type: String }, + status: { type: String, enum: ["approved", "rejected"] }, + actor: { type: personSchema }, + lesson: { type: String }, + date: { type: String }, + time: { type: String }, + }, + { versionKey: false }, +); + +NotificationSchema.index({ userId: 1, createdAt: -1 }); + +export type NotificationTypeDB = InferSchemaType; +export const NotificationModel = model("notification", NotificationSchema); diff --git a/server/src/repositories/commandRepositories/notifications.command.ts b/server/src/repositories/commandRepositories/notifications.command.ts new file mode 100644 index 00000000..22337bf7 --- /dev/null +++ b/server/src/repositories/commandRepositories/notifications.command.ts @@ -0,0 +1,41 @@ +import { injectable } from "inversify"; +import { NotificationModel } from "../../db/schemes/notification.schema.js"; +import { randomUUID } from "node:crypto"; +import { CreateNotificationInput } from "../../types/notifications/notifications.types.js"; + +@injectable() +export class NotificationCommand { + async createNotification( + data: Omit, + ) { + const created = await NotificationModel.create({ + ...data, + id: randomUUID(), + createdAt: new Date().toISOString(), + isRead: false, + }); + + return created.toObject(); + } + + async markAllAsRead(userId: string) { + return await NotificationModel.updateMany( + { userId, isRead: false }, + { $set: { isRead: true } }, + ).exec(); + } + + async markOneAsRead(notificationId: string, userId: string) { + await NotificationModel.updateOne( + { id: notificationId, userId }, + { $set: { isRead: true } }, + ).exec(); + } + + async deleteAllRead(userId: string) { + return NotificationModel.deleteMany({ + userId, + isRead: true, + }).exec(); + } +} diff --git a/server/src/repositories/queryRepositories/notifications.query.ts b/server/src/repositories/queryRepositories/notifications.query.ts new file mode 100644 index 00000000..575dbe5d --- /dev/null +++ b/server/src/repositories/queryRepositories/notifications.query.ts @@ -0,0 +1,12 @@ +import { NotificationModel } from "../../db/schemes/notification.schema.js"; +import { injectable } from "inversify"; + +@injectable() +export class NotificationQuery { + async getNotificationsByUser(userId: string, limit = 30) { + return NotificationModel.find({ userId }) + .sort({ createdAt: -1 }) + .limit(limit) + .lean(); + } +} diff --git a/server/src/routes/notificationsRoute.ts b/server/src/routes/notificationsRoute.ts new file mode 100644 index 00000000..0353af6d --- /dev/null +++ b/server/src/routes/notificationsRoute.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { container } from "../composition/compositionRoot.js"; +import { TYPES } from "../composition/composition.types.js"; +import { AuthMiddleware } from "../middlewares/authMiddlewareWithBearer.js"; +import { NotificationController } from "../controllers/notification.controller.js"; + +export const notificationsRouter = Router(); + +const notificationsController = container.get( + TYPES.NotificationController, +); +const authMiddleware = container.get(TYPES.AuthMiddleware); + +notificationsRouter.get( + "/", + authMiddleware.handle, + notificationsController.getMyNotifications.bind(notificationsController), +); +notificationsRouter.patch( + "/read-all", + authMiddleware.handle, + notificationsController.markAllAsRead.bind(notificationsController), +); + +notificationsRouter.delete( + "/read-all", + authMiddleware.handle, + notificationsController.deleteAllRead.bind(notificationsController), +); + +notificationsRouter.patch( + "/:id/read", + authMiddleware.handle, + notificationsController.markOneAsRead.bind(notificationsController), +); diff --git a/server/src/services/notifications/notifications.service.ts b/server/src/services/notifications/notifications.service.ts new file mode 100644 index 00000000..4dd57cd0 --- /dev/null +++ b/server/src/services/notifications/notifications.service.ts @@ -0,0 +1,35 @@ +import { inject, injectable } from "inversify"; +import { TYPES } from "../../composition/composition.types.js"; +import { NotificationCommand } from "../../repositories/commandRepositories/notifications.command.js"; +import { NotificationQuery } from "../../repositories/queryRepositories/notifications.query.js"; +import { CreateNotificationInput } from "../../types/notifications/notifications.types.js"; + +@injectable() +export class NotificationService { + constructor( + @inject(TYPES.NotificationCommand) + private notificationCommand: NotificationCommand, + @inject(TYPES.NotificationQuery) + private notificationQuery: NotificationQuery, + ) {} + + async createNotification(payload: CreateNotificationInput) { + return this.notificationCommand.createNotification(payload); + } + + async getMyNotifications(userId: string) { + return this.notificationQuery.getNotificationsByUser(userId); + } + + async markAllAsRead(userId: string) { + return this.notificationCommand.markAllAsRead(userId); + } + + async markOneAsRead(notificationId: string, userId: string) { + return this.notificationCommand.markOneAsRead(notificationId, userId); + } + + async deleteAllRead(userId: string) { + return this.notificationCommand.deleteAllRead(userId); + } +} diff --git a/server/src/socket/socket.server.ts b/server/src/socket/socket.server.ts index 2a998a71..df6cf931 100644 --- a/server/src/socket/socket.server.ts +++ b/server/src/socket/socket.server.ts @@ -15,6 +15,7 @@ import { errorToMessage } from "../utils/errorToMessage.js"; import { ChatService } from "../services/chat/chat.service.js"; import { validateChatText } from "./validators/chatText.validator.js"; import { validateConversationId } from "./validators/conversationId.validator.js"; +import { NotificationService } from "../services/notifications/notifications.service.js"; type SocketData = { userId: string; @@ -201,22 +202,41 @@ function registerChatHandlers(_io: Server, socket: Socket) { conversationId: payload.conversationId, unreadCount: result.unreadCount, }); - - _io.to(`user:${result.recipientId}`).emit("notification:new", { - id: crypto.randomUUID(), + const notificationService = container.get( + TYPES.NotificationService, + ); + const notification = await notificationService.createNotification({ + userId: result.recipientId, type: "chatMessages", conversationId: payload.conversationId, - createdAt: new Date().toISOString(), - isRead: false, + sender: result.sender, message: { id: result.message.id, text: result.message.text, senderId: result.message.senderId, createdAt: result.message.createdAt, }, - sender: result.sender, }); + _io + .to(`user:${result.recipientId}`) + .emit("notification:new", notification); + + // _io.to(`user:${result.recipientId}`).emit("notification:new", { + // id: crypto.randomUUID(), + // type: "chatMessages", + // conversationId: payload.conversationId, + // createdAt: new Date().toISOString(), + // isRead: false, + // message: { + // id: result.message.id, + // text: result.message.text, + // senderId: result.message.senderId, + // createdAt: result.message.createdAt, + // }, + // sender: result.sender, + // }); + cb?.({ ok: true, message: result.message }); } catch (e: unknown) { cb?.({ ok: false, error: errorToMessage(e) }); diff --git a/server/src/types/notifications/notifications.types.ts b/server/src/types/notifications/notifications.types.ts new file mode 100644 index 00000000..6e537b63 --- /dev/null +++ b/server/src/types/notifications/notifications.types.ts @@ -0,0 +1,31 @@ +export type CreateNotificationInput = + | { + userId: string; + type: "chatMessages"; + conversationId: string; + sender: { + id: string; + name: string; + imageUrl: string | null; + }; + message: { + id: string; + text: string; + senderId: string; + createdAt: string; + }; + } + | { + userId: string; + type: "appointmentStatus"; + appointmentId: string; + status: "approved" | "rejected"; + actor: { + id: string; + name: string; + imageUrl: string | null; + }; + lesson: string; + date: string; + time: string; + }; From 4154cd766293c9944176f4ac6deeef80a81bd0b6 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:29:01 +0100 Subject: [PATCH 4/4] fix: copilot suggestions --- .../DropdownNotificationsMenu.tsx | 24 +++---- .../controlPanel/IndicatorTrigger.tsx | 15 ++++- .../src/components/headerPrivate/TopBar.tsx | 15 ++++- .../notificationBar/NotificationBar.tsx | 5 +- .../mutation/useDeleteAllReadNotifications.ts | 4 +- .../mutation/useMarkOneNotificationAsRead.tsx | 5 +- .../query/useQueryNotifications.ts | 2 +- client/src/types/notificationFeed.types.ts | 22 ------- .../src/controllers/appointment.controller.ts | 63 +++++++++---------- server/src/controllers/auth.controller.ts | 8 +-- .../notifications.command.ts | 4 +- server/src/socket/socket.server.ts | 57 +++++++---------- 12 files changed, 100 insertions(+), 124 deletions(-) delete mode 100644 client/src/types/notificationFeed.types.ts diff --git a/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx index 7a7f0739..c2fbdcee 100644 --- a/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx +++ b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx @@ -154,14 +154,10 @@ export const DropdownNotificationsMenu = ({ )} >
- -
- {option.sender.name} - - - -
-
+
+ {option.sender.name} + +
{option.message.text} @@ -184,14 +180,10 @@ export const DropdownNotificationsMenu = ({ )} >
- -
- {option.actor.name} - - - -
-
+
+ {option.actor.name} + +
{option.status === "approved" ? "Approved your appointment" diff --git a/client/src/components/controlPanel/IndicatorTrigger.tsx b/client/src/components/controlPanel/IndicatorTrigger.tsx index 663ac557..94ef8f58 100644 --- a/client/src/components/controlPanel/IndicatorTrigger.tsx +++ b/client/src/components/controlPanel/IndicatorTrigger.tsx @@ -8,7 +8,7 @@ import { getAvatarUrl } from "../../api/upload/upload.api"; import type { LinkOption } from "../../types/linkOptionsType"; import { useNotificationFeedStore } from "../../store/notificationFeed.store.ts"; import { twMerge } from "tailwind-merge"; -import { lockScroll } from "../../util/modalScroll.util.ts"; +import { lockScroll, unlockScroll } from "../../util/modalScroll.util.ts"; import { NotificationBar } from "../notificationBar/NotificationBar.tsx"; type Props = { @@ -27,8 +27,17 @@ export const IndicatorTrigger = ({ options, variant = "private" }: Props) => { ); const onOpenNotificationsMenu = () => { - setOpenNotificationMenu(!openNotificationMenu); - lockScroll(); + setOpenNotificationMenu((prev) => { + const next = !prev; + + if (next) { + lockScroll(); + } else { + unlockScroll(); + } + + return next; + }); }; const wrapperClass = diff --git a/client/src/components/headerPrivate/TopBar.tsx b/client/src/components/headerPrivate/TopBar.tsx index 796b6704..c2fc4e63 100644 --- a/client/src/components/headerPrivate/TopBar.tsx +++ b/client/src/components/headerPrivate/TopBar.tsx @@ -4,7 +4,7 @@ import { ProfileIndicator } from "../profileIndicator/ProfileIndicator.tsx"; import { Logo } from "../logo/Logo.tsx"; import { NotificationBar } from "../notificationBar/NotificationBar.tsx"; import { useNotificationFeedStore } from "../../store/notificationFeed.store.ts"; -import { lockScroll } from "../../util/modalScroll.util.ts"; +import { lockScroll, unlockScroll } from "../../util/modalScroll.util.ts"; import { useAuthSessionStore } from "../../store/authSession.store.ts"; export const TopBar = () => { @@ -17,8 +17,17 @@ export const TopBar = () => { const user = useAuthSessionStore((s) => s.user); const onOpenNotificationsMenu = () => { - setOpenNotificationMenu(!openNotificationMenu); - lockScroll(); + setOpenNotificationMenu((prev) => { + const next = !prev; + + if (next) { + lockScroll(); + } else { + unlockScroll(); + } + + return next; + }); }; return ( <> diff --git a/client/src/components/notificationBar/NotificationBar.tsx b/client/src/components/notificationBar/NotificationBar.tsx index f583310e..cbf94636 100644 --- a/client/src/components/notificationBar/NotificationBar.tsx +++ b/client/src/components/notificationBar/NotificationBar.tsx @@ -27,10 +27,13 @@ export const NotificationBar = ({ variant="link" className="relative" onClick={onOpenNotificationsMenu} + aria-label="Notifications" + aria-expanded={openMenu} + aria-haspopup="menu" >
{unreadNotifications}
diff --git a/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts b/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts index 08a4b48e..91327e20 100644 --- a/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts +++ b/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts @@ -7,13 +7,13 @@ import { notificationKeys } from "../../queryKeys.ts"; export const useDeleteAllReadNotifications = () => { const queryClient = useQueryClient(); - const items = useNotificationFeedStore((s) => s.items); - const setItems = useNotificationFeedStore((s) => s.setItems); const notifyError = useNotificationStore((s) => s.error); return useMutation({ mutationFn: deleteAllReadNotifications, onSuccess: () => { + const { items, setItems } = useNotificationFeedStore.getState(); + const unreadOnly = items.filter((item) => !item.isRead); setItems(unreadOnly); diff --git a/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx b/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx index 46367488..eabbf9d9 100644 --- a/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx +++ b/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx @@ -8,18 +8,17 @@ import { getErrorMessage } from "../../../util/ErrorUtil.ts"; export const useMarkOneNotificationAsRead = () => { const queryClient = useQueryClient(); const notifyError = useNotificationStore((s) => s.error); - const setItems = useNotificationFeedStore((s) => s.setItems); - const items = useNotificationFeedStore((s) => s.items); return useMutation({ mutationFn: markNotificationAsRead, onSuccess: (_, notificationId) => { + const { items, setItems } = useNotificationFeedStore.getState(); + const updated = items.map((item) => item.id === notificationId ? { ...item, isRead: true } : item, ); setItems(updated); - queryClient.setQueryData(notificationKeys.root, updated); }, onError: (error) => { diff --git a/client/src/features/notifications/query/useQueryNotifications.ts b/client/src/features/notifications/query/useQueryNotifications.ts index db5e4919..4bb3c11b 100644 --- a/client/src/features/notifications/query/useQueryNotifications.ts +++ b/client/src/features/notifications/query/useQueryNotifications.ts @@ -23,7 +23,7 @@ export const useNotificationsQuery = () => { const msg = getErrorMessage(query.error); notifyError(msg ?? "Failed to load notifications"); } - }, [query.isError, query.isSuccess, query.error, notifyError]); + }, [query.isError, query.error, notifyError]); return query; }; diff --git a/client/src/types/notificationFeed.types.ts b/client/src/types/notificationFeed.types.ts deleted file mode 100644 index ff1a973b..00000000 --- a/client/src/types/notificationFeed.types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AppointmentStatus } from "./appointments.types.ts"; - -export type AppNotification = - | { - type: "chatMessages"; - conversationId: string; - message: { - id: string; - text: string; - senderId: string; - createdAt: string; - }; - } - | { - type: "appointmentStatus"; - appointmentId: string; - status: AppointmentStatus; - teacherId: string; - lesson: string; - date: string; - time: string; - }; diff --git a/server/src/controllers/appointment.controller.ts b/server/src/controllers/appointment.controller.ts index 09703cae..1be3e330 100644 --- a/server/src/controllers/appointment.controller.ts +++ b/server/src/controllers/appointment.controller.ts @@ -21,6 +21,7 @@ import { import { getIO } from "../socket/io.holder.js"; import { TeacherQuery } from "../repositories/queryRepositories/teacher.query.js"; import { NotificationService } from "../services/notifications/notifications.service.js"; +import { logError } from "../utils/logging.js"; @injectable() export class AppointmentController { @@ -134,6 +135,7 @@ export class AppointmentController { next: NextFunction, ) { const io = getIO(); + try { const appointment = await this.appointmentService.updateAppointmentStatus( req.params.id, @@ -144,45 +146,40 @@ export class AppointmentController { return res.status(404).json({ message: "Appointment not found" }); } - const teacher = await this.teacherQuery.getTeacherById( - appointment.teacherId, - ); - - if (!teacher) { - return res.status(404).json({ message: "Teacher not found" }); - } - if ( appointment.status === "approved" || appointment.status === "rejected" ) { - const teacher = await this.teacherQuery.getTeacherById( - appointment.teacherId, - ); - - if (!teacher) { - return res.status(404).json({ message: "Teacher not found" }); - } + try { + const teacher = await this.teacherQuery.getTeacherById( + appointment.teacherId, + ); - const notification = await this.notificationService.createNotification({ - userId: appointment.studentId, - type: "appointmentStatus", - appointmentId: appointment.id, - status: appointment.status, - actor: { - id: teacher.id, - name: `${teacher.firstName} ${teacher.lastName}`.trim(), - imageUrl: teacher.profileImageUrl ?? null, - }, - lesson: appointment.lesson, - date: appointment.date, - time: appointment.time, - }); + if (teacher) { + const notification = + await this.notificationService.createNotification({ + userId: appointment.studentId, + type: "appointmentStatus", + appointmentId: appointment.id, + status: appointment.status, + actor: { + id: teacher.id, + name: `${teacher.firstName} ${teacher.lastName}`.trim(), + imageUrl: teacher.profileImageUrl ?? null, + }, + lesson: appointment.lesson, + date: appointment.date, + time: appointment.time, + }); - io?.to(`user:${appointment.studentId}`).emit( - "notification:new", - notification, - ); + io?.to(`user:${appointment.studentId}`).emit( + "notification:new", + notification, + ); + } + } catch (notificationError) { + logError(notificationError); + } } return res.status(200).json(appointment); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 04f239a6..7d18d202 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -93,7 +93,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === "production", path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", @@ -142,7 +142,7 @@ export class AuthController { res.cookie("refreshToken", newRefreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === "production", path: "/", maxAge: 2 * 60 * 60 * 1000, }); @@ -278,7 +278,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === "production", path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", @@ -312,7 +312,7 @@ export class AuthController { res.cookie("refreshToken", refreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === "production", path: "/", maxAge: 2 * 60 * 60 * 1000, sameSite: "lax", diff --git a/server/src/repositories/commandRepositories/notifications.command.ts b/server/src/repositories/commandRepositories/notifications.command.ts index 22337bf7..63067147 100644 --- a/server/src/repositories/commandRepositories/notifications.command.ts +++ b/server/src/repositories/commandRepositories/notifications.command.ts @@ -5,9 +5,7 @@ import { CreateNotificationInput } from "../../types/notifications/notifications @injectable() export class NotificationCommand { - async createNotification( - data: Omit, - ) { + async createNotification(data: CreateNotificationInput) { const created = await NotificationModel.create({ ...data, id: randomUUID(), diff --git a/server/src/socket/socket.server.ts b/server/src/socket/socket.server.ts index df6cf931..70ed8a05 100644 --- a/server/src/socket/socket.server.ts +++ b/server/src/socket/socket.server.ts @@ -16,6 +16,7 @@ import { ChatService } from "../services/chat/chat.service.js"; import { validateChatText } from "./validators/chatText.validator.js"; import { validateConversationId } from "./validators/conversationId.validator.js"; import { NotificationService } from "../services/notifications/notifications.service.js"; +import { logError } from "../utils/logging.js"; type SocketData = { userId: string; @@ -179,6 +180,9 @@ function registerChatHandlers(_io: Server, socket: Socket) { "chat:sendMessage", async (payload: SendMessagePayload, cb?: (ack: SendAck) => void) => { const validation = validateChatText(payload?.text, { maxLen: 1000 }); + const notificationService = container.get( + TYPES.NotificationService, + ); if (!validation.ok) { cb?.({ ok: false, error: validation.error }); return; @@ -202,40 +206,27 @@ function registerChatHandlers(_io: Server, socket: Socket) { conversationId: payload.conversationId, unreadCount: result.unreadCount, }); - const notificationService = container.get( - TYPES.NotificationService, - ); - const notification = await notificationService.createNotification({ - userId: result.recipientId, - type: "chatMessages", - conversationId: payload.conversationId, - sender: result.sender, - message: { - id: result.message.id, - text: result.message.text, - senderId: result.message.senderId, - createdAt: result.message.createdAt, - }, - }); - _io - .to(`user:${result.recipientId}`) - .emit("notification:new", notification); - - // _io.to(`user:${result.recipientId}`).emit("notification:new", { - // id: crypto.randomUUID(), - // type: "chatMessages", - // conversationId: payload.conversationId, - // createdAt: new Date().toISOString(), - // isRead: false, - // message: { - // id: result.message.id, - // text: result.message.text, - // senderId: result.message.senderId, - // createdAt: result.message.createdAt, - // }, - // sender: result.sender, - // }); + try { + const notification = await notificationService.createNotification({ + userId: result.recipientId, + type: "chatMessages", + conversationId: payload.conversationId, + sender: result.sender, + message: { + id: result.message.id, + text: result.message.text, + senderId: result.message.senderId, + createdAt: result.message.createdAt, + }, + }); + + _io + .to(`user:${result.recipientId}`) + .emit("notification:new", notification); + } catch (notificationError) { + logError(notificationError); + } cb?.({ ok: true, message: result.message }); } catch (e: unknown) {