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 new file mode 100644 index 00000000..c2fbdcee --- /dev/null +++ b/client/src/components/DropdownNotificationsMenu/DropdownNotificationsMenu.tsx @@ -0,0 +1,206 @@ +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"; +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[]; + openMenu: boolean; + setOpenMenu: (open: boolean) => void; + currentRole?: "student" | "teacher" | "moderator"; +}; + +export const DropdownNotificationsMenu = ({ + classNames, + options, + openMenu, + setOpenMenu, + 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" + ? `${studentBase}/${chatRoutes.root}/${option.conversationId}` + : `${teacherBase}/${chatRoutes.root}/${option.conversationId}`; + } + + return currentRole === "student" + ? `${studentBase}/${studentPrivatesRoutesVariables.appointments}` + : `${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"; + } + + 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)) { + onCloseNotificationsMenu(); + } + }; + + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [openMenu]); + + return ( +
+
+
+ + Notifications + + + {hasReadNotifications && ( + + )} +
+
+ {options.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + + {options.map((option) => ( + + {option.type === "chatMessages" ? ( + + ) : ( + + )} + + ))} + + )} +
+
+
+ ); +}; 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/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..94ef8f58 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 { useNotificationFeedStore } from "../../store/notificationFeed.store.ts"; +import { twMerge } from "tailwind-merge"; +import { lockScroll, unlockScroll } from "../../util/modalScroll.util.ts"; +import { NotificationBar } from "../notificationBar/NotificationBar.tsx"; type Props = { options: LinkOption[]; @@ -14,46 +18,75 @@ 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 avatarUrl = getAvatarUrl(user?.profileImageUrl || null); + const notifications = useNotificationFeedStore((s) => s.items); + const unreadNotifications = useNotificationFeedStore( + (s) => s.items.filter((item) => !item.isRead).length, + ); + + const onOpenNotificationsMenu = () => { + setOpenNotificationMenu((prev) => { + const next = !prev; + + if (next) { + lockScroll(); + } else { + unlockScroll(); + } + + return next; + }); + }; const wrapperClass = variant === "private" ? "hidden md:flex items-center" : "flex items-center"; return ( -
+
+ +
- +
+ +
); diff --git a/client/src/components/headerPrivate/TopBar.tsx b/client/src/components/headerPrivate/TopBar.tsx index 65b5f2bd..c2fc4e63 100644 --- a/client/src/components/headerPrivate/TopBar.tsx +++ b/client/src/components/headerPrivate/TopBar.tsx @@ -1,11 +1,34 @@ import React, { useState } from "react"; -import HelpIcon from "../icons/QuestionMark"; import { LogoutConfirmation } from "../auth/logoutConfirmation/LogoutConfirmation"; 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, unlockScroll } from "../../util/modalScroll.util.ts"; +import { useAuthSessionStore } from "../../store/authSession.store.ts"; export const TopBar = () => { 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((prev) => { + const next = !prev; + + if (next) { + lockScroll(); + } else { + unlockScroll(); + } + + return next; + }); + }; return ( <>
@@ -15,28 +38,17 @@ export const TopBar = () => {
{/* Search */} - {/**/} - {/* */} - {/* */} - {/*
*/}
- +
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/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/notificationBar/NotificationBar.tsx b/client/src/components/notificationBar/NotificationBar.tsx new file mode 100644 index 00000000..cbf94636 --- /dev/null +++ b/client/src/components/notificationBar/NotificationBar.tsx @@ -0,0 +1,50 @@ +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/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/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/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/features/notifications/mutation/useDeleteAllReadNotifications.ts b/client/src/features/notifications/mutation/useDeleteAllReadNotifications.ts new file mode 100644 index 00000000..91327e20 --- /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 notifyError = useNotificationStore((s) => s.error); + + return useMutation({ + mutationFn: deleteAllReadNotifications, + onSuccess: () => { + const { items, setItems } = useNotificationFeedStore.getState(); + + 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..eabbf9d9 --- /dev/null +++ b/client/src/features/notifications/mutation/useMarkOneNotificationAsRead.tsx @@ -0,0 +1,29 @@ +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); + + 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) => { + 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..4bb3c11b --- /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.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 new file mode 100644 index 00000000..9525fed3 --- /dev/null +++ b/client/src/hooks/useNotificationsRealtime.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useSocketStore } from "../store/socket.store.ts"; +import { + AppNotification, + useNotificationFeedStore, +} from "../store/notificationFeed.store.ts"; + +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..435efc00 100644 --- a/client/src/layouts/RootLayout.tsx +++ b/client/src/layouts/RootLayout.tsx @@ -16,6 +16,8 @@ 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"; +import { useHydrateNotifications } from "../hooks/useHydrateNotifications.tsx"; type IncomingCallSignal = VideoCallResponse & { callId?: string }; export const RootLayout = () => { @@ -38,12 +40,11 @@ export const RootLayout = () => { useAuthInit(); useMeQuery(); useMouseFollowEffect(); - useAudioUnlock(); - + useHydrateNotifications(); useUnreadChatSync(); useSocketConnection(); - + useNotificationsRealtime(); useEffect(() => { // Student listens for incoming call event. if (!isStudent || !socket) return; 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/client/src/store/notificationFeed.store.ts b/client/src/store/notificationFeed.store.ts new file mode 100644 index 00000000..c67ab409 --- /dev/null +++ b/client/src/store/notificationFeed.store.ts @@ -0,0 +1,63 @@ +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; + }; + +type NotificationFeedState = { + items: AppNotification[]; + 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, ...state.items], + })), + + markAllAsReadLocal: () => + 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/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 98c0ea79..1be3e330 100644 --- a/server/src/controllers/appointment.controller.ts +++ b/server/src/controllers/appointment.controller.ts @@ -18,12 +18,19 @@ import { validatePaginationParams, validateAuthorization, } 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"; +import { logError } from "../utils/logging.js"; @injectable() export class AppointmentController { constructor( @inject(TYPES.AppointmentService) protected appointmentService: AppointmentService, + @inject(TYPES.TeacherQuery) protected teacherQuery: TeacherQuery, + @inject(TYPES.NotificationService) + protected notificationService: NotificationService, ) {} async createAppointmentController( @@ -127,6 +134,8 @@ export class AppointmentController { res: Response, next: NextFunction, ) { + const io = getIO(); + try { const appointment = await this.appointmentService.updateAppointmentStatus( req.params.id, @@ -137,6 +146,42 @@ export class AppointmentController { return res.status(404).json({ message: "Appointment not found" }); } + if ( + appointment.status === "approved" || + appointment.status === "rejected" + ) { + try { + const teacher = await this.teacherQuery.getTeacherById( + appointment.teacherId, + ); + + 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, + ); + } + } catch (notificationError) { + logError(notificationError); + } + } + 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..63067147 --- /dev/null +++ b/server/src/repositories/commandRepositories/notifications.command.ts @@ -0,0 +1,39 @@ +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: CreateNotificationInput) { + 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/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/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 e5607793..70ed8a05 100644 --- a/server/src/socket/socket.server.ts +++ b/server/src/socket/socket.server.ts @@ -15,6 +15,8 @@ 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"; +import { logError } from "../utils/logging.js"; type SocketData = { userId: string; @@ -178,17 +180,21 @@ 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; } 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 +207,27 @@ function registerChatHandlers(_io: Server, socket: Socket) { unreadCount: result.unreadCount, }); + 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) { 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; + };