diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 05de0cc81..076a6a575 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -3,6 +3,7 @@ import { ErrorPage } from "@litespace/ui/ErrorPage"; import { lazy } from "react"; import { Dashboard } from "@litespace/utils/routes"; import Page from "@/components/Layout/Page"; +import { LessonEvents } from "@/pages/LessonEvents"; const Root = lazy(() => import("@/pages/Root")); const Invoices = lazy(() => import("@/pages/Invoices")); @@ -65,6 +66,10 @@ const router = createBrowserRouter([ path: Dashboard.PlanInvites, element: } />, }, + { + path: Dashboard.LessonEvents, + element: } />, + }, ], }, ]); diff --git a/apps/dashboard/src/components/Common/DateTimeField.tsx b/apps/dashboard/src/components/Common/DateTimeField.tsx new file mode 100644 index 000000000..18ce55bd5 --- /dev/null +++ b/apps/dashboard/src/components/Common/DateTimeField.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { dayjs } from "@/lib/dayjs"; +import { Tooltip } from "@litespace/ui/Tooltip"; +import { Typography } from "@litespace/ui/Typography"; + +const DateTimeField: React.FC<{ date: string }> = ({ date }) => { + return ( + + + {dayjs(date).format("dddd D MMMM YYYY hh:mm a")} +
({dayjs(date).fromNow()}) +
+ + } + > +
+ + {dayjs(date).format("YYYY/MM/D hh:mm A")} + +
+
+ ); +}; + +export default DateTimeField; diff --git a/apps/dashboard/src/components/LessonEvents/EventsTable.tsx b/apps/dashboard/src/components/LessonEvents/EventsTable.tsx new file mode 100644 index 000000000..e8dc0bc3b --- /dev/null +++ b/apps/dashboard/src/components/LessonEvents/EventsTable.tsx @@ -0,0 +1,116 @@ +import { ISessionEvent } from "@litespace/types"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import dayjs from "@/lib/dayjs"; +import DateTimeField from "@/components/Common/DateTimeField"; +import { createColumnHelper } from "@tanstack/react-table"; +import { useCallback, useMemo } from "react"; +import { Table } from "@litespace/ui/Table"; + +interface EventsTableProps { + title: string; + events: ISessionEvent.MetaSelf[]; +} + +const EventsTable: React.FC = ({ title, events }) => { + const intl = useFormatMessage(); + + const getEventType = useCallback( + (type: number) => { + if (type === 1) return intl("dashboard.lesson-events.joined"); + if (type === 2) return intl("dashboard.lesson-events.left"); + return intl("dashboard.lesson-events.unknown"); + }, + [intl] + ); + + const columnHelper = createColumnHelper(); + + const columns = useMemo( + () => [ + columnHelper.accessor("type", { + header: intl("global.labels.type"), + cell: (info) => ( + + {getEventType(info.getValue())} + + ), + }), + columnHelper.accessor("createdAt", { + header: intl("global.labels.time"), + cell: (info) => ( +
+ + + ({dayjs(info.getValue()).fromNow()}) + +
+ ), + }), + columnHelper.accessor("createdAt", { + id: "timeRelativeToSession", + header: intl("dashboard.lesson-events.time-relative-to-session"), + cell: (info) => { + const eventTime = dayjs(info.getValue()); + const sessionStart = dayjs(info.row.original.sessionStart); + const minutesDiff = eventTime.diff(sessionStart, "minutes"); + + const sign = minutesDiff === 0 ? "" : minutesDiff > 0 ? "+" : ""; + const formattedValue = `${sign}${Math.abs(minutesDiff)}`; + + const textColor = + minutesDiff < 0 + ? "text-blue-600" + : minutesDiff === 0 + ? "text-gray-500" + : "text-green-600"; + + return ( +
+ {formattedValue} {intl("global.labels.minutes")} +
+ {minutesDiff < 0 + ? intl("dashboard.lesson-events.before-session") + : minutesDiff > 0 + ? intl("dashboard.lesson-events.after-session") + : intl("dashboard.lesson-events.at-session")} +
+
+ ); + }, + }), + ], + [columnHelper, getEventType, intl] + ); + + if (events.length === 0) { + return ( +
+

+ {intl("dashboard.lesson-events.events")} {title} +

+

+ {intl("dashboard.lesson-events.no-events")} +

+
+ ); + } + + return ( +
+
+

+ {intl("dashboard.lesson-events.events")} {title} +

+
+ + + ); +}; + +export default EventsTable; diff --git a/apps/dashboard/src/components/LessonEvents/LessonEventsSummary.tsx b/apps/dashboard/src/components/LessonEvents/LessonEventsSummary.tsx new file mode 100644 index 000000000..2a5e7b0bd --- /dev/null +++ b/apps/dashboard/src/components/LessonEvents/LessonEventsSummary.tsx @@ -0,0 +1,190 @@ +import { ILesson, ISessionEvent, IUser } from "@litespace/types"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import dayjs from "@/lib/dayjs"; +import { createColumnHelper } from "@tanstack/react-table"; +import { useMemo, useCallback } from "react"; +import { Table } from "@litespace/ui/Table"; +import UserPopover from "@/components/Common/UserPopover"; +import { LocalId } from "@litespace/ui/locales"; +import { + calculateAttendanceTime, + calculatePunctuality, + getFirstJoinEvent, + getLastLeaveEvent, +} from "@/lib/lessonEvents"; + +interface LessonEventsSummaryProps { + lesson: { + lesson: ILesson.Self; + members: ILesson.PopulatedMember[]; + }; + events: { + tutor: ISessionEvent.MetaSelf[]; + student: ISessionEvent.MetaSelf[]; + }; +} + +const LessonEventsSummary: React.FC = ({ + lesson, + events, +}) => { + const intl = useFormatMessage(); + const { lesson: lessonDetails, members } = lesson; + const sessionStart = dayjs(lessonDetails.start); + const sessionEnd = sessionStart.add(lessonDetails.duration, "minutes"); + + const participants = useMemo( + () => [ + { + role: "tutor", + events: events.tutor, + member: members.find( + (m) => + m.role === IUser.Role.TutorManager || m.role === IUser.Role.Tutor + ), + }, + { + role: "student", + events: events.student, + member: members.find((m) => m.role === IUser.Role.Student), + }, + ], + [events, members] + ); + + const getAttendanceTime = useCallback( + ( + firstJoin: ISessionEvent.MetaSelf | null, + lastLeave: ISessionEvent.MetaSelf | null + ) => { + return calculateAttendanceTime( + firstJoin, + lastLeave, + lessonDetails.duration + ); + }, + [lessonDetails.duration] + ); + + const getCombinedStatusText = useCallback( + ( + joinStatus: string, + leaveStatus: string, + hasJoined: boolean, + hasLeft: boolean + ) => { + const joinStatusMap: Record = { + "on-time": "dashboard.lesson-events.punctuality.join.on-time", + early: "dashboard.lesson-events.punctuality.join.early", + late: "dashboard.lesson-events.punctuality.join.late", + absent: "dashboard.lesson-events.punctuality.join.absent", + }; + + const leaveStatusMap: Record = { + "on-time": "dashboard.lesson-events.punctuality.leave.on-time", + early: "dashboard.lesson-events.punctuality.leave.early", + late: "dashboard.lesson-events.punctuality.leave.late", + absent: "dashboard.lesson-events.punctuality.leave.absent", + }; + + if (!hasJoined) return intl(joinStatusMap["absent"]); + if (!hasLeft) + return `${joinStatusMap[joinStatus]} - ${intl("dashboard.lesson-events.punctuality.leave.absent")}`; + + return `${intl(joinStatusMap[joinStatus])} - ${intl(leaveStatusMap[leaveStatus])}`; + }, + [intl] + ); + + const columnHelper = createColumnHelper<(typeof participants)[0]>(); + + const columns = useMemo( + () => [ + columnHelper.accessor("member", { + header: intl("dashboard.lesson-events.participant"), + cell: (info) => , + }), + columnHelper.display({ + id: "combined-status", + header: intl("dashboard.lesson-events.combined-status"), + cell: (info) => { + const events = info.row.original.events as ISessionEvent.MetaSelf[]; + const first = getFirstJoinEvent(events); + const last = getLastLeaveEvent(events); + + const hasJoined = !!first; + const hasLeft = !!last; + + const joinStatus = calculatePunctuality( + first?.createdAt || null, + sessionStart, + true + ); + + const leaveStatus = calculatePunctuality( + last?.createdAt || null, + sessionEnd, + false + ); + + const statusText = getCombinedStatusText( + joinStatus, + leaveStatus, + hasJoined, + hasLeft + ); + + return ( + + {statusText} + + ); + }, + }), + columnHelper.accessor("events", { + header: intl("dashboard.lesson-events.total-attendance"), + cell: (info) => { + const events = info.getValue() as ISessionEvent.MetaSelf[]; + const first = getFirstJoinEvent(events); + const last = getLastLeaveEvent(events); + const { minutes, percentage } = getAttendanceTime(first, last); + + return ( +
+ {minutes} {intl("global.labels.minutes")} ({percentage}%) +
+ ); + }, + }), + ], + [ + columnHelper, + getAttendanceTime, + getCombinedStatusText, + sessionStart, + intl, + sessionEnd, + ] + ); + + return ( +
+
+

+ {intl("dashboard.lesson-events.attendance-summary")} +

+
+ +
+ + ); +}; + +export default LessonEventsSummary; diff --git a/apps/dashboard/src/components/LessonEvents/LessonSummary.tsx b/apps/dashboard/src/components/LessonEvents/LessonSummary.tsx new file mode 100644 index 000000000..46a3d2238 --- /dev/null +++ b/apps/dashboard/src/components/LessonEvents/LessonSummary.tsx @@ -0,0 +1,87 @@ +import { ILesson } from "@litespace/types"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import dayjs from "@/lib/dayjs"; +import DateTimeField from "@/components/Common/DateTimeField"; +import { Duration, price } from "@litespace/utils"; +import { formatCurrency } from "@litespace/ui/utils"; +import UserPopover from "@/components/Common/UserPopover"; +import LabelsTable from "@/components/Common/LabelsTable"; + +interface LessonSummaryProps { + lesson: { + lesson: ILesson.Self; + members: ILesson.PopulatedMember[]; + }; +} + +const LessonSummary: React.FC = ({ lesson }) => { + const intl = useFormatMessage(); + const { lesson: lessonDetails, members } = lesson; + const start = dayjs(lessonDetails.start); + const end = start.add(lessonDetails.duration, "minutes"); + + return ( +
+

+ {intl("dashboard.lesson-events.lesson-summary")} +

+ + , + }, + { + label: intl("dashboard.lessons.end"), + value: , + }, + { + label: intl("dashboard.lessons.duration"), + value: Duration.from(lessonDetails.duration.toString()).format( + "ar" + ), + }, + { + label: intl("dashboard.lessons.price"), + value: formatCurrency(price.unscale(lessonDetails.price)), + }, + { + label: intl("dashboard.lessons.members"), + value: members.map((member) => ( +
+ +
+ )), + }, + { + label: intl("dashboard.lesson-events.status"), + value: lessonDetails.canceledAt ? ( + + {intl("dashboard.lesson-events.cancelled")} + + ) : dayjs().isBefore(start) ? ( + + {intl("dashboard.lesson-events.scheduled")} + + ) : dayjs().isBetween(start, end) ? ( + + {intl("dashboard.lesson-events.ongoing")} + + ) : ( + + {intl("dashboard.lesson-events.completed")} + + ), + }, + ]} + /> +
+ ); +}; + +export default LessonSummary; diff --git a/apps/dashboard/src/components/LessonEvents/index.tsx b/apps/dashboard/src/components/LessonEvents/index.tsx new file mode 100644 index 000000000..a967afd06 --- /dev/null +++ b/apps/dashboard/src/components/LessonEvents/index.tsx @@ -0,0 +1,87 @@ +import { ILesson, ISessionEvent } from "@litespace/types"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import { LoadingFragment } from "@/components/Common/LoadingFragment"; +import LessonSummary from "@/components/LessonEvents/LessonSummary"; +import EventsTable from "@/components/LessonEvents/EventsTable"; +import LessonEventsSummary from "@/components/LessonEvents/LessonEventsSummary"; + +interface ILessonData { + lesson: { + lesson: ILesson.Self; + members: ILesson.PopulatedMember[]; + }; + events: { + tutor: ISessionEvent.MetaSelf[]; + student: ISessionEvent.MetaSelf[]; + }; +} + +interface LessonEventsProps { + data: ILessonData | null; + loading?: boolean; + error?: Error | null; + refetch: () => void; +} + +const LessonEvents: React.FC = ({ + data, + loading = false, + error = null, + refetch, +}) => { + const intl = useFormatMessage(); + + // Loading and error states + if (loading || error) { + return ( + + ); + } + + if (!data) { + return ( +
+

+ {intl("dashboard.lesson-events.no-data")} +

+
+ ); + } + + return ( +
+ + +
+ + +
+
+ ); +}; + +export default LessonEvents; diff --git a/apps/dashboard/src/components/Lessons/List.tsx b/apps/dashboard/src/components/Lessons/List.tsx index 931d94d6e..b6eb93985 100644 --- a/apps/dashboard/src/components/Lessons/List.tsx +++ b/apps/dashboard/src/components/Lessons/List.tsx @@ -11,6 +11,9 @@ import UserPopover from "@/components/Common/UserPopover"; import DateField from "@/components/Common/DateField"; import { dayjs } from "@/lib/dayjs"; import { LoadingFragment } from "@/components/Common/LoadingFragment"; +import { Link } from "react-router-dom"; +import { router } from "@/lib/route"; +import { Dashboard } from "@litespace/utils/routes"; const List: React.FC<{ query: UseQueryResult; @@ -29,7 +32,17 @@ const List: React.FC<{ return [ columnHelper.accessor("lesson.id", { header: intl("labels.id"), - cell: (info) => info.getValue(), + cell: (info) => ( + + {info.getValue()} + + ), }), columnHelper.accessor((row) => row.members, { header: intl("dashboard.lessons.members"), diff --git a/apps/dashboard/src/components/Tutors/Content.tsx b/apps/dashboard/src/components/Tutors/Content.tsx index a75608e34..94d4e489f 100644 --- a/apps/dashboard/src/components/Tutors/Content.tsx +++ b/apps/dashboard/src/components/Tutors/Content.tsx @@ -68,7 +68,7 @@ export const Content: React.FC<{ diff --git a/apps/dashboard/src/lib/lessonEvents.ts b/apps/dashboard/src/lib/lessonEvents.ts new file mode 100644 index 000000000..9529149f4 --- /dev/null +++ b/apps/dashboard/src/lib/lessonEvents.ts @@ -0,0 +1,59 @@ +import { ISessionEvent } from "@litespace/types"; +import dayjs from "@/lib/dayjs"; + +export const getFirstJoinEvent = (events: ISessionEvent.MetaSelf[]) => { + return events + .filter((event) => event.type === 1) + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + )[0]; +}; + +export const getLastLeaveEvent = (events: ISessionEvent.MetaSelf[]) => { + return events + .filter((event) => event.type === 2) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + )[0]; +}; + +export const calculatePunctuality = ( + eventTime: string | null, + sessionTime: dayjs.Dayjs, + isJoin: boolean +) => { + if (!eventTime) return "absent"; + + const event = dayjs(eventTime); + const diffMinutes = event.diff(sessionTime, "minute"); + + // For join: [-3, +1] = on time, < -3 = early, > +1 = late + // For leave: [-1, +3] = on time, < -1 = early, > +3 = late + const [earlyThreshold, onTimeStart, onTimeEnd, lateThreshold] = isJoin + ? [-3, -3, 1, 1] + : [-1, -1, 3, 3]; + + if (diffMinutes >= onTimeStart && diffMinutes <= onTimeEnd) return "on-time"; + if (isJoin ? diffMinutes < earlyThreshold : diffMinutes < lateThreshold) + return "early"; + return "late"; +}; + +export const calculateAttendanceTime = ( + firstJoin: ISessionEvent.MetaSelf | null, + lastLeave: ISessionEvent.MetaSelf | null, + duration: number +) => { + if (!firstJoin || !lastLeave) return { minutes: 0, percentage: 0 }; + + const start = dayjs(firstJoin.createdAt); + const end = dayjs(lastLeave.createdAt); + const minutes = end.diff(start, "minute"); + + return { + minutes, + percentage: Math.min(100, Math.round((minutes / duration) * 100)), + }; +}; diff --git a/apps/dashboard/src/pages/LessonEvents.tsx b/apps/dashboard/src/pages/LessonEvents.tsx new file mode 100644 index 000000000..5997011b3 --- /dev/null +++ b/apps/dashboard/src/pages/LessonEvents.tsx @@ -0,0 +1,46 @@ +import PageTitle from "@/components/Common/PageTitle"; +import List from "@/components/LessonEvents"; +import { useFindLesson } from "@litespace/headless/lessons"; +import { useFindSessionEventsBySessionId } from "@litespace/headless/sessionEvent"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import cn from "classnames"; +import React, { useMemo } from "react"; +import { useParams } from "react-router-dom"; + +type LessonEventsParams = { + lessonId: string; +}; + +export const LessonEvents: React.FC = () => { + const intl = useFormatMessage(); + const params = useParams(); + + const lessonId = useMemo(() => Number(params.lessonId), [params]); + + const lesson = useFindLesson(lessonId); + const events = useFindSessionEventsBySessionId(lesson.data?.lesson.sessionId); + + return ( +
+
+ +
+ + {lesson.data && events.data ? ( + + ) : null} +
+ ); +}; + +export default LessonEvents; diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 830fb594c..1b3be0840 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/components/Plans/Selector.tsx b/apps/web/src/components/Plans/Selector.tsx index 21577aa96..7837c1aae 100644 --- a/apps/web/src/components/Plans/Selector.tsx +++ b/apps/web/src/components/Plans/Selector.tsx @@ -72,7 +72,7 @@ export const Selector: React.FC<{
-
+
{sortedPlans.map((plan, idx) => { const index = idx < description.current.length diff --git a/apps/web/src/components/TutorProfile/ProfileCard.tsx b/apps/web/src/components/TutorProfile/ProfileCard.tsx index 6600041ea..63174ea00 100644 --- a/apps/web/src/components/TutorProfile/ProfileCard.tsx +++ b/apps/web/src/components/TutorProfile/ProfileCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import Star from "@litespace/assets/Star"; import { Void } from "@litespace/types"; import cn from "classnames"; @@ -10,6 +10,10 @@ import { formatNumber } from "@litespace/ui/utils"; import { Button } from "@litespace/ui/Button"; import { useMediaQuery } from "@litespace/headless/mediaQuery"; import { StudentDashboardTour } from "@/constants/tour"; +import Chat from "@litespace/assets/Chat"; +import { useFindRoomByMembers } from "@litespace/headless/chat"; +import { useUser } from "@litespace/headless/context/user"; +import { useNavigate } from "react-router-dom"; const ACHIEVEMENTS_DISPLAY_THRETHOLD = 5; @@ -21,6 +25,7 @@ export const ProfileCard: React.FC<{ studentCount: number; lessonCount: number; avgRating: number; + ratingCount: number; onBook?: Void; loading?: boolean; error?: boolean; @@ -33,34 +38,51 @@ export const ProfileCard: React.FC<{ studentCount, lessonCount, avgRating, + ratingCount, loading, error, onBook, retry, }) => { const intl = useFormatMessage(); + const { user } = useUser(); const { sm } = useMediaQuery(); + const navigate = useNavigate(); useEffect(() => { StudentDashboardTour.goNext(); }, []); + const room = useFindRoomByMembers([user!.id, id]); + const goToRoom = useCallback(() => { + if (room.query.data) navigate(`/chat?room=${room.query.data}`); + navigate(`/chat?room=t-${id}`); + }, [room.query.data, id, navigate]); + const BookButton = useMemo( () => ( - + + {intl("tutor.book")} + + +
), - [intl, onBook] + [intl, onBook, goToRoom] ); if (loading) @@ -158,6 +180,12 @@ export const ProfileCard: React.FC<{ })} + + ({intl("tutor-profile.rating", { count: ratingCount })}) +
) : null}
diff --git a/apps/web/src/components/TutorProfile/ProfileInfo.tsx b/apps/web/src/components/TutorProfile/ProfileInfo.tsx index 93f9ae94e..e1f39ffa4 100644 --- a/apps/web/src/components/TutorProfile/ProfileInfo.tsx +++ b/apps/web/src/components/TutorProfile/ProfileInfo.tsx @@ -1,5 +1,6 @@ import { StudentDashboardTour } from "@/constants/tour"; import { track } from "@/lib/ga"; +import { useMediaQuery } from "@litespace/headless/mediaQuery"; import { useFormatMessage } from "@litespace/ui/hooks/intl"; import { Typography } from "@litespace/ui/Typography"; import { VideoPlayer } from "@litespace/ui/VideoPlayer"; @@ -13,8 +14,26 @@ const ProfileInfo: React.FC<{ video: string | null; }> = ({ about, topics, video }) => { const intl = useFormatMessage(); + const { md } = useMediaQuery(); + return (
+ {video && !md ? ( +
+ + {intl("tutor.profile.tabs.profile.video")} + + { + track("play_tutor_video", "tutor_profile"); + }} + src={optional(video)} + /> +
+ ) : null}
{about ? (
@@ -61,7 +80,7 @@ const ProfileInfo: React.FC<{
) : null}
- {video ? ( + {video && md ? (
{ const [temporaryTutor, setTemporaryTutor] = @@ -27,6 +28,21 @@ const Chat: React.FC = () => { const { query: roomMembers, keys } = useFindRoomMembers( typeof selected.room === "number" ? selected.room : null ); + const { data: uncontactedTutorData } = useFindTutorInfo( + typeof selected.room === `string` && Number(selected.room.split("-")[1]) + ? Number(selected.room.split("-")[1]) + : undefined + ); + + useEffect(() => { + if (uncontactedTutorData) + setTemporaryTutor({ + ...uncontactedTutorData, + online: false, + gender: IUser.Gender.Male, + lastSeen: "", + }); + }, [uncontactedTutorData]); useEffect(() => { if (roomMembers.error?.message === ApiError.NotRoomMember) diff --git a/apps/web/src/pages/Root.tsx b/apps/web/src/pages/Root.tsx index c000d7f92..a5e32fd82 100644 --- a/apps/web/src/pages/Root.tsx +++ b/apps/web/src/pages/Root.tsx @@ -107,20 +107,20 @@ const Root: React.FC = () => { // @note: tutor should automatically be directed to the onboarding page // incase he didn't passed one or more of the onboarding flow steps. tutor // should only be redirected if his profile is completed. - if ( - tutor && - completedProfile && - meta && - !settings && - !publicRoute && - !router.match(Web.Interview, location.pathname) && - !router.match(Web.DemoSession, location.pathname) && - !meta.bypassOnboarding && - (!meta.passedIntroVideo || - !meta.passedInterview || - !meta.passedDemoSession) - ) - return navigate(Web.TutorOnboarding); + // if ( + // tutor && + // completedProfile && + // meta && + // !settings && + // !publicRoute && + // !router.match(Web.Interview, location.pathname) && + // !router.match(Web.DemoSession, location.pathname) && + // !meta.bypassOnboarding && + // (!meta.passedIntroVideo || + // !meta.passedInterview || + // !meta.passedDemoSession) + // ) + // return navigate(Web.TutorOnboarding); // ============ student redirect ======== if (role.student && root) { diff --git a/apps/web/src/pages/TutorProfile.tsx b/apps/web/src/pages/TutorProfile.tsx index 600ee1081..111097e5f 100644 --- a/apps/web/src/pages/TutorProfile.tsx +++ b/apps/web/src/pages/TutorProfile.tsx @@ -7,7 +7,7 @@ import { useSearchParams, } from "react-router-dom"; import { useFindTutorInfo } from "@litespace/headless/tutor"; -import RightArrow from "@litespace/assets/ArrowRight"; +import RightArrowHead from "@litespace/assets/RightArrowHead"; import { Typography } from "@litespace/ui/Typography"; import { useFormatMessage } from "@litespace/ui/hooks/intl"; import { TutorTabs } from "@/components/TutorProfile/TutorTabs"; @@ -48,7 +48,7 @@ const TutorProfile: React.FC = () => { to={Web.Tutors} className="hidden md:flex w-6 h-6 items-center justify-center" > - + ) : null} { {tutor.data?.name ? ( <> / - + {tutor.data.name} @@ -87,7 +87,7 @@ const TutorProfile: React.FC = () => { ) : null} {!tutor.isLoading && !tutor.isError && tutor.data ? ( -
+
{ diff --git a/packages/atlas/src/api/lesson.ts b/packages/atlas/src/api/lesson.ts index b5cb53415..183a69d4a 100644 --- a/packages/atlas/src/api/lesson.ts +++ b/packages/atlas/src/api/lesson.ts @@ -1,5 +1,5 @@ import { Base } from "@/lib/base"; -import { ILesson } from "@litespace/types"; +import { ILesson, ISession } from "@litespace/types"; export class Lesson extends Base { async create( @@ -48,6 +48,12 @@ export class Lesson extends Base { return await this.get({ route: `/api/v1/lesson/${id}` }); } + async findBySessionId( + id: ISession.Id + ): Promise { + return await this.get({ route: `/api/v1/lesson/session/${id}` }); + } + async update( payload: ILesson.UpdateApiPayload ): Promise { diff --git a/packages/atlas/src/api/sessionEvent.ts b/packages/atlas/src/api/sessionEvent.ts index 151f5dd87..c9534be21 100644 --- a/packages/atlas/src/api/sessionEvent.ts +++ b/packages/atlas/src/api/sessionEvent.ts @@ -10,4 +10,12 @@ export class SessionEvent extends Base { params: query, }); } + + async findBySessionId({ + sessionId, + }: ISessionEvent.FindBySessionIdApiQuery): Promise { + return await this.get({ + route: `/api/v1/session-event/list/session/${sessionId}`, + }); + } } diff --git a/packages/headless/src/constants/query.ts b/packages/headless/src/constants/query.ts index 1fdfdef42..a39c65c51 100644 --- a/packages/headless/src/constants/query.ts +++ b/packages/headless/src/constants/query.ts @@ -19,6 +19,7 @@ export enum QueryKey { FindLessons = "find-lessons", FindRefundableLessons = "find-refundable-lessons", FindLesson = "find-lesson", + FindLessonBySessionId = "find-lesson-by-session-id", FindAvailabilitySlots = "find-availability-slots", FindInfiniteLessons = "find-infinite-lessons", FindWithdrawalMethods = "find-withdrawal-methods", @@ -39,6 +40,7 @@ export enum QueryKey { FindTutorInfo = "find-tutor-info", FindUserTopics = "find-user-topics", FindSessionMembers = "find-session-members", + FindEventsByLessonId = "find-events-by-lesson-id", FindSessionEvents = "find-session-events", FindRoomByMembers = "find-room-by-members", FindStudioTutor = "find-studio-tutor", diff --git a/packages/headless/src/lessons.ts b/packages/headless/src/lessons.ts index ca68ff805..02d80df9d 100644 --- a/packages/headless/src/lessons.ts +++ b/packages/headless/src/lessons.ts @@ -1,4 +1,4 @@ -import { Element, IFilter, ILesson } from "@litespace/types"; +import { Element, IFilter, ILesson, ISession } from "@litespace/types"; import { useCallback } from "react"; import { useApi } from "@/api/index"; import { MutationKey, QueryKey } from "@/constants"; @@ -48,6 +48,20 @@ export function useFindRefundableLessons(): UseQueryResult await api.lesson.findBySessionId(sessionId), + [api.lesson, sessionId] + ); + + return useQuery({ + queryFn: findBySessionId, + queryKey: [QueryKey.FindLessonBySessionId, sessionId], + }); +} + /** * Paginate lessons using infinite pagination. */ diff --git a/packages/headless/src/sessionEvent.ts b/packages/headless/src/sessionEvent.ts index cccfff405..5be0399f5 100644 --- a/packages/headless/src/sessionEvent.ts +++ b/packages/headless/src/sessionEvent.ts @@ -2,7 +2,8 @@ import { useApi } from "@/api"; import { useCallback } from "react"; import { QueryKey } from "@/constants"; import { usePaginate } from "@/pagination"; -import { ISessionEvent } from "@litespace/types"; +import { ISession, ISessionEvent } from "@litespace/types"; +import { useQuery } from "@tanstack/react-query"; export function useFindSessionEvents() { const api = useApi(); @@ -15,3 +16,17 @@ export function useFindSessionEvents() { return usePaginate(findSessionMembers, [QueryKey.FindSessionMembers]); } + +export function useFindSessionEventsBySessionId(sessionId?: ISession.Id) { + const api = useApi(); + + const findBySessionId = useCallback(async () => { + if (!sessionId) return { tutor: [], student: [] }; + return await api.sessionEvent.findBySessionId({ sessionId }); + }, [api.sessionEvent, sessionId]); + + return useQuery({ + queryFn: findBySessionId, + queryKey: [QueryKey.FindEventsByLessonId, sessionId], + }); +} diff --git a/packages/models/src/ratings.ts b/packages/models/src/ratings.ts index 20abb6f97..6bcef6edd 100644 --- a/packages/models/src/ratings.ts +++ b/packages/models/src/ratings.ts @@ -234,16 +234,22 @@ export class Ratings { users: number[]; }>): Promise { const rows = await this.builder(tx) - .select({ user: this.column.ratings("ratee_id") }) - .avg>({ + .select({ + user: this.column.ratings("ratee_id"), + }) + .avg({ avg: this.column.ratings("value"), }) + .count({ + count: this.column.ratings("id"), + }) .whereIn(this.column.ratings("ratee_id"), users) .groupBy(this.column.ratings("ratee_id")); return rows.map((row) => ({ user: row.user, avg: zod.coerce.number().parse(row.avg), + count: zod.coerce.number().parse(row.count), })); } diff --git a/packages/models/src/sessionEvents.ts b/packages/models/src/sessionEvents.ts index 0b84184e9..17fdca8f9 100644 --- a/packages/models/src/sessionEvents.ts +++ b/packages/models/src/sessionEvents.ts @@ -6,7 +6,7 @@ import { withPagination, } from "@/query"; import { first, isEmpty } from "lodash"; -import { ISessionEvent, Paginated } from "@litespace/types"; +import { ISessionEvent, IUser, Paginated } from "@litespace/types"; import { Knex } from "knex"; import dayjs from "@/lib/dayjs"; import { users } from "@/users"; @@ -137,6 +137,55 @@ export class SessionEvents { }; } + async findBySessionId({ + sessionId, + tx, + }: WithOptionalTx): Promise { + const rows = await this.builder(tx) + .select( + this.column("id"), + this.column("type"), + this.column("user_id"), + this.column("session_id"), + this.column("created_at"), + users.column("name"), + users.column("role"), + "lessons.start as session_start" + ) + .join(users.table, this.column("user_id"), users.column("id")) + .join( + lessons.table.lessons, + this.column("session_id"), + lessons.columns.lessons("session_id") + ) + .where(this.column("session_id"), sessionId) + .orderBy(this.column("created_at")); + + const events = rows.map((row) => ({ + id: row.id, + type: row.type, + userId: row.user_id, + userName: row.name, + sessionId: row.session_id, + createdAt: row.created_at || row.created_at.toISOString(), + sessionStart: row.session_start || row.session_start.toISOString(), + role: row.role, + })); + // Separate events into tutors and students + const tutor: ISessionEvent.MetaSelf[] = []; + const student: ISessionEvent.MetaSelf[] = []; + + events.forEach((event) => { + if (event.role === IUser.Role.Student) { + student.push(event); + } else { + tutor.push(event); + } + }); + + return { tutor, student }; + } + from(row: ISessionEvent.Row): ISessionEvent.Self { return { id: row.id, diff --git a/packages/models/src/tutors.ts b/packages/models/src/tutors.ts index 6bf472f16..64be44024 100644 --- a/packages/models/src/tutors.ts +++ b/packages/models/src/tutors.ts @@ -470,7 +470,7 @@ export class Tutors { q2.where("s1.activated", "=", true); q2.whereNotNull("s1.video"); q2.whereNotNull("s1.thumbnail"); - q2.whereNotNull("s1.studio_id"); + // q2.whereNotNull("s1.studio_id"); } ); }); @@ -488,6 +488,8 @@ export class Tutors { withPagination(innerSelect.clone(), pagination) ); + console.log({ list }); + return { list, total }; } diff --git a/packages/models/tests/cache/tutor.test.ts b/packages/models/tests/cache/tutor.test.ts index dc6f6eef8..5439740d6 100644 --- a/packages/models/tests/cache/tutor.test.ts +++ b/packages/models/tests/cache/tutor.test.ts @@ -20,6 +20,7 @@ const getMockTutorCache = (id: number) => ({ notice: faker.number.int(), topics: faker.lorem.words(5).split(" "), avgRating: faker.number.float(), + ratingCount: faker.number.int(), studentCount: faker.number.int(), lessonCount: faker.number.int(), }); diff --git a/packages/types/src/lesson.ts b/packages/types/src/lesson.ts index 1d086abb4..42bc04cb4 100644 --- a/packages/types/src/lesson.ts +++ b/packages/types/src/lesson.ts @@ -215,11 +215,20 @@ export type LessonDay = { export type LessonDayRows = LessonDayRow[]; export type LessonDays = LessonDay[]; +export type FindBySessionIdApiQuery = { + sessionId: ISession.Id; +}; + export type FindLessonByIdApiResponse = { lesson: Self; members: PopulatedMember[]; }; +export type FindLessonBySessionIdApiResponse = { + lesson: Self; + members: PopulatedMember[]; +}; + export type FindAttendedLessonsStatsApiQuery = { /** * an iso datetime string diff --git a/packages/types/src/rating.ts b/packages/types/src/rating.ts index 1093ca076..12b861e1a 100644 --- a/packages/types/src/rating.ts +++ b/packages/types/src/rating.ts @@ -107,6 +107,10 @@ export type FindTutorRatingsApiQuery = IFilter.Pagination; export type FindTutorRatingsApiResponse = Paginated; -export type FindAvgRatingResult = Array<{ user: number; avg: number }>; +export type FindAvgRatingResult = Array<{ + user: number; + avg: number; + count: number; +}>; export type FindRatingByIdApiResponse = Populated; diff --git a/packages/types/src/sessionEvent.ts b/packages/types/src/sessionEvent.ts index 53bbf10d7..081c5718f 100644 --- a/packages/types/src/sessionEvent.ts +++ b/packages/types/src/sessionEvent.ts @@ -45,3 +45,19 @@ export type FindModelQuery = Pagination & { export type FindApiQuery = FindModelQuery; export type FindApiResponse = Paginated; + +export type FindBySessionIdApiQuery = { + sessionId: ISession.Id; +}; + +export type FindBySessionIdModelQuery = FindBySessionIdApiQuery; + +export type FindBySessionIdModelResponse = { + tutor: MetaSelf[]; + student: MetaSelf[]; +}; + +export type FindBySessionIdApiResponse = { + tutor: MetaSelf[]; + student: MetaSelf[]; +}; diff --git a/packages/types/src/tutor.ts b/packages/types/src/tutor.ts index 1010b06f6..9b2914ed2 100644 --- a/packages/types/src/tutor.ts +++ b/packages/types/src/tutor.ts @@ -64,6 +64,7 @@ export type Cache = { notice: number; topics: string[]; avgRating: number; + ratingCount: number; studentCount: number; lessonCount: number; }; @@ -146,6 +147,7 @@ export type FindTutorInfoApiResponse = { studentCount: number; lessonCount: number; avgRating: number; + ratingCount: number; notice: number; }; diff --git a/packages/ui/src/hooks/chat.ts b/packages/ui/src/hooks/chat.ts index 7195942c1..626af838f 100644 --- a/packages/ui/src/hooks/chat.ts +++ b/packages/ui/src/hooks/chat.ts @@ -1,5 +1,6 @@ import { IRoom } from "@litespace/types"; import { useCallback, useState } from "react"; +import { useSearchParams } from "react-router-dom"; export type UncontactedTutorRoomId = `t-${number}`; @@ -13,16 +14,16 @@ export type SelectRoom = (payload: { otherMember: IRoom.FindUserRoomsApiRecord["otherMember"] | null; }) => void; -/** Make be needed in future const ROOM_URL_PARAM = "room"; -const ROOM_CACHE_KEY = "litespace:chat::room"; - -function asRoomId(room: string | null): number | null { +function asRoomId(room: string | null): number | UncontactedTutorRoomId | null { if (!room) return null; const id = Number(room); - if (Number.isNaN(id)) return null; + if (Number.isNaN(id)) return room as UncontactedTutorRoomId; return id; } +/** Make be needed in future +const ROOM_CACHE_KEY = "litespace:chat::room"; + function saveRoom(room: number) { localStorage.setItem(ROOM_CACHE_KEY, room.toString()); return room; @@ -33,23 +34,32 @@ function getCachedRoom() { return asRoomId(room); } -function getRoomParam(params: URLSearchParams): number | null { +*/ +function getRoomParam( + params: URLSearchParams +): number | UncontactedTutorRoomId | null { const room = params.get(ROOM_URL_PARAM); return asRoomId(room); } -*/ export function asTutorRoomId(tutorId: number): UncontactedTutorRoomId { return `t-${tutorId}`; } export function useSelectedRoom() { + const [params, setParams] = useSearchParams(); const [selected, setSelected] = useState({ - room: null, + room: getRoomParam(params) || null, otherMember: null, }); - const select: SelectRoom = useCallback((payload) => setSelected(payload), []); + const select: SelectRoom = useCallback( + (payload) => { + setSelected(payload); + setParams(`room=${payload.room}`); + }, + [setParams] + ); return { selected, diff --git a/packages/ui/src/locales/ar-eg.json b/packages/ui/src/locales/ar-eg.json index 36f176593..09280b062 100644 --- a/packages/ui/src/locales/ar-eg.json +++ b/packages/ui/src/locales/ar-eg.json @@ -230,6 +230,7 @@ "tutor.rating.group-names.n-students": "{count} من طلاب {tutor}", "tutor-profile.loading": "برجاء الإنتظار... جاري تحميل بيانات المعلم!", "tutor-profile.error": "عذرًا، حدث خطأ أثناء تحميل بيانات المعلم، برجاء المحاولة مرة أخرى", + "tutor-profile.rating": "{count} تقييم", "student-dashboard.rating-dialog.add-comment-placeholder": "اكتب تعليقك هنا", "rating-dialog.submit": "ارسال التقييم", "rating-dialog.rate-tutor.title": "قيم معلمك", @@ -609,12 +610,48 @@ "dashboard.session-events.username": "اسم المستخدم", "dashboard.session-events.start": "موعد الجلسة", "dashboard.session-events.created-at": "تاريخ النشاط", + "dashboard.lesson-events.lesson-summary": "ملخص الجلسة", + "dashboard.lesson-events.tutor": "المعلم", + "dashboard.lesson-events.student": "الطالب", + "dashboard.lesson-events.joined": "انضم", + "dashboard.lesson-events.left": "غادر", + "dashboard.lesson-events.unknown": "غير معروف", + "dashboard.lesson-events.status": "الحالة", + "dashboard.lesson-events.cancelled": "ملغى", + "dashboard.lesson-events.scheduled": "قيد الانتظار", + "dashboard.lesson-events.ongoing": "جاري", + "dashboard.lesson-events.completed": "منتهي", + "dashboard.lesson-events.no-events": "لم يدخل هذا المستخدم الجلسة", + "dashboard.lesson-events.loading": "جاري تحميل أحداث الجلسة...", + "dashboard.lesson-events.error": "خطأ في تحميل أحداث الجلسة", + "dashboard.lesson-events.no-data": "لا توجد بيانات درس متاحة", + "global.labels.type": "النوع", + "global.labels.time": "الوقت", + "dashboard.lessons.end": "موعد الانتهاء", + "dashboard.lesson-events.events": "نشاطات", "dashboard.labels.price-before-discount": "قبل الخصم: {value} ج.م", "dashboard.labels.price-after-discount": "بعد الخصم: {value} ج.م", "dashboard.labels.price-diff": "الفرق: {value} ج.م", "dashboard.labels.price-discount": "الخصم: {value}", "dashboard.labels.price-with-discount": "{price} ج.م ({discount})", "dashboard.labels.tutor-schedule": "جدول المعلم", + "dashboard.lesson-events.attendance-summary": "ملخص الحضور", + "dashboard.lesson-events.participant": "المشارك", + "dashboard.lesson-events.total-attendance": "إجمالي وقت الحضور", + "dashboard.lesson-events.punctuality.join.on-time": "حضر في الموعد", + "dashboard.lesson-events.punctuality.join.early": "حضر مبكرًا", + "dashboard.lesson-events.punctuality.join.late": "حضر متأخرًا", + "dashboard.lesson-events.punctuality.join.absent": "لم يحضر", + "dashboard.lesson-events.punctuality.leave.on-time": "غادر في الموعد", + "dashboard.lesson-events.punctuality.leave.early": "غادر مبكرًا", + "dashboard.lesson-events.punctuality.leave.late": "غادر متأخرًا", + "dashboard.lesson-events.punctuality.leave.absent": "لم يغادر", + "dashboard.lesson-events.combined-status": "حالة الحضور والمغادرة", + "dashboard.lesson-events.time-relative-to-session": "الوقت بالنسبة لبداية الجلسة", + "dashboard.lesson-events.before-session": "قبل الجلسة", + "dashboard.lesson-events.after-session": "بعد الجلسة", + "dashboard.lesson-events.at-session": "عند بدء الجلسة", + "global.labels.minutes": "د", "webrtc-check.title": "تنبيه: متصفحك لا يدعم تقنيه WebRTC", "webrtc-check.description": "بعض وظائف المنصة تتطلب تقنيه WebRTC و التي لا يدعمها متصفحك الحالي، للحصول علي افضل تجربة نوصي باستخدام متصفح مثل Chrome او Firefox او Safari او Edge باحدث اصداراته.", "webrtc-check.confirm": "فتح جوجل كروم", @@ -753,8 +790,8 @@ "shared-settings.verify-code.success": "تم تفعيل الإشعارات بنجاح", "shared-settings.verify-code.error": "حدث خطأ أثناء التحقق من رمز التفعيل، برجاء المحاولة مرة أخرى!", "shared-settings.notifications": "الإشعارات", - "shared-settings.notifications.lesson-date.title": "إشعارات تاريخ الدرس", - "shared-settings.notifications.lesson-date.description": "سيتم اشعارك بميعاد الدرس قبله بيوم و قبله بساعه", + "shared-settings.notifications.lesson-date.title": "إشعارات تاريخ الجلسة", + "shared-settings.notifications.lesson-date.description": "سيتم اشعارك بميعاد الجلسة قبله بيوم و قبله بساعه", "shared-settings.notifications.messages.title": "إشعارات الرسائل", "shared-settings.notifications.messages.description": "سوف يتم اشعارك بجميع الرسائل الواردة", "shared-settings.notifications.coming-soon.title": "قريبًا 🎉", @@ -773,9 +810,9 @@ "lessons.canceled-by-tutor": "لقد تم إلغاء الحصة من قبل المعلم", "lessons.canceled-by-student": "لقد قام الطالب بإلغاء الحصة", "lessons.canceled-by-you": "لقد قمت بإلغاء الحصة", - "lessons.time-to-join": "باقي علي الدرس {value}", - "lessons.time-to-end-lesson": "باقي علي انتهاء الدرس {value}", - "lessons.time-from-start": "بدأ الدرس منذ {value} دقائق", + "lessons.time-to-join": "باقي علي الجلسة {value}", + "lessons.time-to-end-lesson": "باقي علي انتهاء الجلسة {value}", + "lessons.time-from-start": "بدأ الجلسة منذ {value} دقائق", "lessons.can-join-now": "يمكنك الالتحاق الآن", "lessons.end": "انتهت الحصة", "lessons.menu.edit": "تعديل الموعد", @@ -1175,7 +1212,7 @@ "error.api.user-not-found": "المستخدم غير موجود، برجاء التحقق من البيانات.", "error.api.tutor-not-found": "المعلم غير موجود، برجاء التحقق من البيانات.", "error.api.student-not-found": "الطالب غير موجود، برجاء التحقق من البيانات.", - "error.api.lesson-not-found": "الدرس غير موجود، برجاء التحقق من البيانات.", + "error.api.lesson-not-found": "الجلسة غير موجودة، برجاء التحقق من البيانات.", "error.api.lesson-not-started": "عذرا، الحصة لم تبدأ بعد.", "error.api.lesson-time-passed": "لقد انتهى وقت الحصة بالفعل!", "error.api.lesson-already-started": "لقد بدأ وقت الحصة بالفعل", diff --git a/packages/utils/src/routes/core.ts b/packages/utils/src/routes/core.ts index 978fa0294..214619923 100644 --- a/packages/utils/src/routes/core.ts +++ b/packages/utils/src/routes/core.ts @@ -152,6 +152,11 @@ type DashboardPayload = id: number; query?: BaseQuery; } + | { + route: Dashboard.LessonEvents; + lessonId: number; + query?: BaseQuery; + } | { route: Exclude; query?: BaseQuery; diff --git a/packages/utils/src/routes/route.ts b/packages/utils/src/routes/route.ts index 805a61a6b..b8432826b 100644 --- a/packages/utils/src/routes/route.ts +++ b/packages/utils/src/routes/route.ts @@ -65,6 +65,7 @@ export enum Dashboard { Tutors = "/tutors", PlanInvites = "/plan-invites", SessionEvents = "/session-events", + LessonEvents = "/lessons/:lessonId", } export type StudentSettingsTabId = diff --git a/services/server/src/handlers/lesson.ts b/services/server/src/handlers/lesson.ts index 3a7f99393..9c604dbff 100644 --- a/services/server/src/handlers/lesson.ts +++ b/services/server/src/handlers/lesson.ts @@ -7,6 +7,7 @@ import { jsonBoolean, pageNumber, pageSize, + sessionId, withNamedId, } from "@/validation/utils"; import { @@ -33,7 +34,13 @@ import { lessons, knex, availabilitySlots } from "@litespace/models"; import safeRequest from "express-async-handler"; import { ApiContext } from "@/types/api"; import { calculateLessonPrice } from "@litespace/utils/lesson"; -import { isAdmin, isStudent, isTutor, isUser } from "@litespace/utils/user"; +import { + isAdmin, + isStudent, + isTutor, + isTutorManager, + isUser, +} from "@litespace/utils/user"; import { MAX_FULL_FLAG_DAYS } from "@/constants"; import { isEmpty, isEqual } from "lodash"; import { @@ -82,6 +89,11 @@ const updateLessonPayload: ZodSchema = zod.object({ duration: duration, }); +const findBySessionIdQuery: ZodSchema = + zod.object({ + sessionId: sessionId, + }); + const findLessonsQuery: ZodSchema = zod.object({ users: zod.optional(zod.array(id)), page: zod.optional(pageNumber), @@ -506,7 +518,8 @@ async function findLessonById(req: Request, res: Response, next: NextFunction) { if (!lesson || isEmpty(members)) return next(notfound.lesson()); const isMember = !!members.find((member) => member.userId === user.id); - if (!isMember) return next(forbidden()); + if (!isMember && !isAdmin(user) && !isTutorManager(user)) + return next(forbidden()); const response: ILesson.FindLessonByIdApiResponse = { lesson, @@ -523,6 +536,30 @@ async function findLessonById(req: Request, res: Response, next: NextFunction) { res.status(200).json(response); } +async function findBySessionId( + req: Request, + res: Response, + next: NextFunction +) { + const user = req.user; + const allowed = isAdmin(user) || isTutorManager(user); + if (!allowed) return next(forbidden()); + + const { sessionId } = findBySessionIdQuery.parse(req.params); + + const lesson = await lessons.findBySessionId(sessionId); + if (!lesson) return next(bad()); + + const members = await lessons.findLessonMembers([lesson.id]); + + const response: ILesson.FindLessonBySessionIdApiResponse = { + lesson, + members, + }; + + res.status(200).json(response); +} + async function cancel(req: Request, res: Response, next: NextFunction) { const user = req.user; const allowed = isStudent(user) || isTutor(user); @@ -610,6 +647,7 @@ export default { report: safeRequest(report), findLessons: safeRequest(findLessons), findLessonById: safeRequest(findLessonById), + findBySessionId: safeRequest(findBySessionId), findAttendedLessonsStats: safeRequest(findAttendedLessonsStats), findRefundableLessons: safeRequest(findRefundableLessons), createWithCard: safeRequest(createWithCard), diff --git a/services/server/src/handlers/sessionEvent.ts b/services/server/src/handlers/sessionEvent.ts index a0b6df11d..919d6458f 100644 --- a/services/server/src/handlers/sessionEvent.ts +++ b/services/server/src/handlers/sessionEvent.ts @@ -1,9 +1,9 @@ import { forbidden } from "@/lib/error/api"; -import { isAdmin } from "@litespace/utils/user"; +import { isAdmin, isTutorManager } from "@litespace/utils/user"; import { ISessionEvent } from "@litespace/types"; import { NextFunction, Request, Response } from "express"; import safeRequest from "express-async-handler"; -import { ids, pageNumber, pageSize } from "@/validation/utils"; +import { ids, pageNumber, pageSize, sessionId } from "@/validation/utils"; import zod, { ZodSchema } from "zod"; import { sessionEvents } from "@litespace/models"; @@ -14,6 +14,11 @@ const findQuery: ZodSchema = zod.object({ page: pageNumber.optional(), }); +const findBySessionIdQuery: ZodSchema = + zod.object({ + sessionId: sessionId, + }); + async function find(req: Request, res: Response, next: NextFunction) { const user = req.user; const allowed = isAdmin(user); @@ -26,6 +31,28 @@ async function find(req: Request, res: Response, next: NextFunction) { res.status(200).json(response); } +async function findBySessionId( + req: Request, + res: Response, + next: NextFunction +) { + const user = req.user; + const allowed = isAdmin(user) || isTutorManager(user); + if (!allowed) return next(forbidden()); + + const { sessionId } = findBySessionIdQuery.parse(req.params); + + const events = await sessionEvents.findBySessionId({ sessionId }); + + const response: ISessionEvent.FindBySessionIdApiResponse = { + tutor: events.tutor, + student: events.student, + }; + + res.status(200).json(response); +} + export default { find: safeRequest(find), + findBySessionId: safeRequest(findBySessionId), }; diff --git a/services/server/src/lib/tutor.ts b/services/server/src/lib/tutor.ts index a8ca14159..2613d74cc 100644 --- a/services/server/src/lib/tutor.ts +++ b/services/server/src/lib/tutor.ts @@ -64,6 +64,8 @@ export async function constructTutorsCache(): Promise { topics: filteredTopics, avgRating: tutorsRatings.find((rating) => rating.user === tutor.id)?.avg || 0, + ratingCount: + tutorsRatings.find((rating) => rating.user === tutor.id)?.count || 0, studentCount: tutorsStudentsCount.find((item) => item.userId === tutor.id)?.count || 0, @@ -172,6 +174,7 @@ async function findTutorCacheMeta(tutorId: number) { return { topics: tutorTopics.map((topic) => topic.name.ar), avgRating: first(avgRatings)?.avg || 0, + ratingCount: first(avgRatings)?.count || 0, studentCount, lessonCount, online, @@ -186,6 +189,7 @@ export async function joinTutorCache( ? { topics: cacheData.topics, avgRating: cacheData.avgRating, + ratingCount: cacheData.ratingCount, studentCount: cacheData.studentCount, lessonCount: cacheData.lessonCount, } @@ -224,6 +228,7 @@ export async function asTutorInfoResponseBody( studentCount: ctutor.studentCount, lessonCount: ctutor.lessonCount, avgRating: ctutor.avgRating, + ratingCount: ctutor.ratingCount, notice: ctutor.notice, ...assets, }; diff --git a/services/server/src/routes/lesson.ts b/services/server/src/routes/lesson.ts index 5c33ea76d..baf265333 100644 --- a/services/server/src/routes/lesson.ts +++ b/services/server/src/routes/lesson.ts @@ -9,6 +9,7 @@ export default function router(context: ApiContext) { router.get("/list", lesson.findLessons); router.get("/refundable", lesson.findRefundableLessons); router.get("/:id", lesson.findLessonById); + router.get("/session/:sessionId", lesson.findBySessionId); router.post("/", lesson.create(context)); router.post("/card", lesson.createWithCard); router.post("/ewallet", lesson.createWithEWallet); diff --git a/services/server/src/routes/sessionEvent.ts b/services/server/src/routes/sessionEvent.ts index a9ade86de..854d4168e 100644 --- a/services/server/src/routes/sessionEvent.ts +++ b/services/server/src/routes/sessionEvent.ts @@ -4,5 +4,6 @@ import sessionEvent from "@/handlers/sessionEvent"; const router = Router(); router.get("/list", sessionEvent.find); +router.get("/list/session/:sessionId", sessionEvent.findBySessionId); export default router;