From 970d62d78ece2e572f2e91df11d69f1862befabd Mon Sep 17 00:00:00 2001 From: Mostafa Kamar Date: Sun, 6 Apr 2025 12:51:02 +0200 Subject: [PATCH] feat(server): implemented evaluation of lesson attendance --- packages/headless/src/lessons.ts | 4 +- packages/models/src/sessionEvents.ts | 12 +- packages/types/src/lesson.ts | 8 +- services/server/src/handlers/lesson.ts | 69 +++++++++- services/server/src/lib/lesson.ts | 57 ++++++++ services/server/tests/lib/lessons.test.ts | 154 ++++++++++++++++++++++ 6 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 services/server/src/lib/lesson.ts create mode 100644 services/server/tests/lib/lessons.test.ts diff --git a/packages/headless/src/lessons.ts b/packages/headless/src/lessons.ts index b2d9e4a7b..21bb3560f 100644 --- a/packages/headless/src/lessons.ts +++ b/packages/headless/src/lessons.ts @@ -12,7 +12,9 @@ export function useFindLessons({ ...query }: ILesson.FindLessonsApiQuery & { userOnly?: boolean }): UsePaginateResult<{ lesson: ILesson.Self; - members: ILesson.PopuldatedMember[]; + members: (ILesson.PopuldatedMember & { + attended: boolean; + })[]; }> { const api = useApi(); diff --git a/packages/models/src/sessionEvents.ts b/packages/models/src/sessionEvents.ts index eac27a0ac..c6f6d0204 100644 --- a/packages/models/src/sessionEvents.ts +++ b/packages/models/src/sessionEvents.ts @@ -1,6 +1,6 @@ import { column, knex, WithOptionalTx } from "@/query"; import { first, isEmpty } from "lodash"; -import { ISessionEvent } from "@litespace/types"; +import { ISession, ISessionEvent } from "@litespace/types"; import { Knex } from "knex"; import dayjs from "@/lib/dayjs"; @@ -12,6 +12,7 @@ export class SessionEvents { tx?: Knex.Transaction ): Promise { const now = dayjs.utc().toDate(); + const rows = await this.builder(tx) .insert({ type: event.type, @@ -66,10 +67,12 @@ export class SessionEvents { async find({ users, sessionIds, + events, tx, }: WithOptionalTx<{ users?: number[]; - sessionIds?: number[]; + sessionIds?: ISession.Id[]; + events?: ISessionEvent.EventType[]; }>): Promise { const builder = this.builder(tx).select("*"); @@ -79,7 +82,10 @@ export class SessionEvents { if (sessionIds && !isEmpty(sessionIds)) builder.whereIn(this.column("session_id"), sessionIds); - const rows = await builder.then(); + if (events && !isEmpty(events)) + builder.whereIn(this.column("type"), events); + + const rows = await builder.orderBy("created_at", "asc").then(); return rows.map((row) => this.from(row)); } diff --git a/packages/types/src/lesson.ts b/packages/types/src/lesson.ts index fb84230b1..b115f44f4 100644 --- a/packages/types/src/lesson.ts +++ b/packages/types/src/lesson.ts @@ -145,7 +145,9 @@ export type FindLessonsApiQuery = IFilter.SkippablePagination & { export type FindUserLessonsApiResponse = Paginated<{ lesson: Self; - members: PopuldatedMember[]; + members: (PopuldatedMember & { + attended: boolean; + })[]; }>; export enum Duration { @@ -168,5 +170,7 @@ export type LessonDays = LessonDay[]; export type FindLessonByIdApiResponse = { lesson: Self; - members: PopuldatedMember[]; + members: (PopuldatedMember & { + attended: boolean; + })[]; }; diff --git a/services/server/src/handlers/lesson.ts b/services/server/src/handlers/lesson.ts index 303c491ef..78c69cc96 100644 --- a/services/server/src/handlers/lesson.ts +++ b/services/server/src/handlers/lesson.ts @@ -16,7 +16,7 @@ import { forbidden, notfound, } from "@/lib/error"; -import { ILesson, IUser, Wss } from "@litespace/types"; +import { ILesson, ISession, ISessionEvent, IUser, Wss } from "@litespace/types"; import { lessons, users, @@ -24,6 +24,7 @@ import { rooms, availabilitySlots, interviews, + sessionEvents, } from "@litespace/models"; import { Knex } from "knex"; import safeRequest from "express-async-handler"; @@ -42,6 +43,7 @@ import { asSubSlots, canBook } from "@litespace/utils/availabilitySlots"; import { isEmpty, isEqual } from "lodash"; import { genSessionId } from "@litespace/utils"; import { withImageUrls } from "@/lib/user"; +import { evaluateAttendance } from "@/lib/lesson"; const createLessonPayload = zod.object({ tutorId: id, @@ -275,6 +277,50 @@ async function findLessons(req: Request, res: Response, next: NextFunction) { await lessons.findLessonMembers(userLesonsIds) ); + // Extract sessionIds and create a mapping of lessons to their respective members + const lessonSessions = new Map< + ISession.Id, + { userIds: number[]; duration: number } + >(); + + userLessons.forEach((lesson) => { + const members = lessonMembers.filter( + (member) => member.lessonId === lesson.id + ); + lessonSessions.set(lesson.sessionId, { + userIds: members.map((m) => m.userId), + duration: lesson.duration, + }); + }); + + const sessionIds = Array.from(lessonSessions.keys()); + const events = await sessionEvents.find({ + sessionIds, + users: lessonMembers.map((member) => member.userId), + events: [ + ISessionEvent.EventType.UserJoined, + ISessionEvent.EventType.UserLeft, + ], + }); + + const eventsBySession = new Map(); + events.forEach((event) => { + if (!eventsBySession.has(event.sessionId)) { + eventsBySession.set(event.sessionId, []); + } + eventsBySession.get(event.sessionId)!.push(event); + }); + + const attendanceBySession = new Map>(); + + lessonSessions.forEach(({ userIds, duration }, sessionId) => { + const sessionEvents = eventsBySession.get(sessionId) || []; + attendanceBySession.set( + sessionId, + evaluateAttendance({ events: sessionEvents, userIds, duration }) + ); + }); + const result: ILesson.FindUserLessonsApiResponse = { list: userLessons.map((lesson) => { const members = lessonMembers @@ -284,6 +330,9 @@ async function findLessons(req: Request, res: Response, next: NextFunction) { // mask private information phone: null, verifiedPhone: false, + attended: !!attendanceBySession.get(lesson.sessionId)?.[ + member.userId + ], })); return { lesson, members }; }), @@ -308,6 +357,23 @@ async function findLessonById(req: Request, res: Response, next: NextFunction) { const isMember = !!members.find((member) => member.userId === user.id); if (!isMember) return next(forbidden()); + const memberIds = members.map((member) => member.userId); + + const events = await sessionEvents.find({ + sessionIds: [lesson.sessionId], + users: memberIds, + events: [ + ISessionEvent.EventType.UserJoined, + ISessionEvent.EventType.UserLeft, + ], + }); + + const attendance = evaluateAttendance({ + events, + userIds: memberIds, + duration: lesson.duration, + }); + const response: ILesson.FindLessonByIdApiResponse = { lesson, members: members.map((member) => ({ @@ -315,6 +381,7 @@ async function findLessonById(req: Request, res: Response, next: NextFunction) { // mask private information phone: null, verifiedPhone: false, + attended: attendance[member.userId], })), }; diff --git a/services/server/src/lib/lesson.ts b/services/server/src/lib/lesson.ts new file mode 100644 index 000000000..52a4b2bfb --- /dev/null +++ b/services/server/src/lib/lesson.ts @@ -0,0 +1,57 @@ +import dayjs from "@/lib/dayjs"; +import { ISessionEvent } from "@litespace/types"; + +const MIN_ATTENDANCE_REQUIRED = 0.25; + +export function evaluateAttendance({ + events, + userIds, + duration, +}: { + events: ISessionEvent.Self[]; + userIds: number[]; + duration: number; +}): Record { + const attendanceStatus: Record = Object.fromEntries( + userIds.map((id) => [id, false]) + ); + + const userEventsMap = new Map(); + + events.forEach((event) => { + if (userIds.includes(event.userId)) { + if (!userEventsMap.has(event.userId)) userEventsMap.set(event.userId, []); + + userEventsMap.get(event.userId)?.push(event); + } + }); + + userEventsMap.forEach((events, userId) => { + let totalTime = 0; + let lastJoinTime: number | null = null; + + for (const event of events) { + if (event.type === ISessionEvent.EventType.UserJoined) { + if (lastJoinTime !== null) continue; + + lastJoinTime = dayjs.utc(event.createdAt).valueOf(); + } + + if (event.type === ISessionEvent.EventType.UserLeft) { + if (lastJoinTime === null) continue; + + const leftTime = dayjs.utc(event.createdAt).valueOf(); + totalTime += leftTime - lastJoinTime; + lastJoinTime = null; + } + } + + const minAttendanceMs = MIN_ATTENDANCE_REQUIRED * duration * 60 * 1000; // minimum attendance in ms + + console.log({ minAttendanceMs, totalTime }); + + attendanceStatus[userId] = totalTime >= minAttendanceMs; + }); + + return attendanceStatus; +} diff --git a/services/server/tests/lib/lessons.test.ts b/services/server/tests/lib/lessons.test.ts new file mode 100644 index 000000000..ea8073349 --- /dev/null +++ b/services/server/tests/lib/lessons.test.ts @@ -0,0 +1,154 @@ +import dayjs from "@/lib/dayjs"; +import { evaluateAttendance } from "@/lib/lesson"; +import { ISessionEvent } from "@litespace/types"; + +describe("evaluateAttendance", () => { + const SESSION_ID = 1; + let now: dayjs.Dayjs; + + beforeAll(() => { + now = dayjs.utc(); + }); + + // Helper function to create event objects + function createEvent( + userId: number, + type: ISessionEvent.EventType, + minutesAgo?: number + ): ISessionEvent.Self { + return { + id: Math.random(), // Mock ID + userId, + type, + sessionId: `lesson:${SESSION_ID}`, + createdAt: minutesAgo + ? now.subtract(minutesAgo, "minute").toISOString() + : now.toISOString(), + }; + } + + it("should pass when user attends >25% of 30min lesson", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 10), // Joined 10 mins ago + createEvent(1, ISessionEvent.EventType.UserLeft, 0), // Left now + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(true); + }); + + it("should fail when user attends <25% of 15min lesson", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 3), // Joined 3 mins ago + createEvent(1, ISessionEvent.EventType.UserLeft, 0), // Left now + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 15, + }); + + expect(result[1]).toBe(false); + }); + + it("should handle multiple join-leave cycles", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 15), // 5 mins + createEvent(1, ISessionEvent.EventType.UserLeft, 10), + createEvent(1, ISessionEvent.EventType.UserJoined, 8), // 8 mins + createEvent(1, ISessionEvent.EventType.UserLeft, 0), + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(true); // Total 13 mins (43%) + }); + + it("should ignore events from other users", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 10), + createEvent(2, ISessionEvent.EventType.UserLeft, 0), // Different user + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(false); + }); + + it("should handle exact 25% threshold", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 7.5), + createEvent(1, ISessionEvent.EventType.UserLeft, 0), + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(true); + }); + + it("should mark user absent if only join exists", () => { + const events = [createEvent(1, ISessionEvent.EventType.UserJoined, 10)]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(false); + }); + + it("should correctly evaluate multiple users", () => { + const events = [ + // User 1: 10 minutes (33%) + createEvent(1, ISessionEvent.EventType.UserJoined, 10), + createEvent(1, ISessionEvent.EventType.UserLeft, 0), + // User 2: 5 minutes (16%) + createEvent(2, ISessionEvent.EventType.UserJoined, 5), + createEvent(2, ISessionEvent.EventType.UserLeft, 0), + ]; + + const result = evaluateAttendance({ + events, + userIds: [1, 2], + duration: 30, + }); + + expect(result[1]).toBe(true); + expect(result[2]).toBe(false); + }); + + it("should ignore consecutive joins without leaves", () => { + const events = [ + createEvent(1, ISessionEvent.EventType.UserJoined, 20), + createEvent(1, ISessionEvent.EventType.UserJoined, 15), // Ignored + createEvent(1, ISessionEvent.EventType.UserLeft, 5), + ]; + + const result = evaluateAttendance({ + events, + userIds: [1], + duration: 30, + }); + + expect(result[1]).toBe(true); + }); +});