Skip to content

Commit 970d62d

Browse files
feat(server): implemented evaluation of lesson attendance
1 parent c75fee7 commit 970d62d

File tree

6 files changed

+297
-7
lines changed

6 files changed

+297
-7
lines changed

packages/headless/src/lessons.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ export function useFindLessons({
1212
...query
1313
}: ILesson.FindLessonsApiQuery & { userOnly?: boolean }): UsePaginateResult<{
1414
lesson: ILesson.Self;
15-
members: ILesson.PopuldatedMember[];
15+
members: (ILesson.PopuldatedMember & {
16+
attended: boolean;
17+
})[];
1618
}> {
1719
const api = useApi();
1820

packages/models/src/sessionEvents.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { column, knex, WithOptionalTx } from "@/query";
22
import { first, isEmpty } from "lodash";
3-
import { ISessionEvent } from "@litespace/types";
3+
import { ISession, ISessionEvent } from "@litespace/types";
44
import { Knex } from "knex";
55
import dayjs from "@/lib/dayjs";
66

@@ -12,6 +12,7 @@ export class SessionEvents {
1212
tx?: Knex.Transaction
1313
): Promise<ISessionEvent.Self> {
1414
const now = dayjs.utc().toDate();
15+
1516
const rows = await this.builder(tx)
1617
.insert({
1718
type: event.type,
@@ -66,10 +67,12 @@ export class SessionEvents {
6667
async find({
6768
users,
6869
sessionIds,
70+
events,
6971
tx,
7072
}: WithOptionalTx<{
7173
users?: number[];
72-
sessionIds?: number[];
74+
sessionIds?: ISession.Id[];
75+
events?: ISessionEvent.EventType[];
7376
}>): Promise<ISessionEvent.Self[]> {
7477
const builder = this.builder(tx).select("*");
7578

@@ -79,7 +82,10 @@ export class SessionEvents {
7982
if (sessionIds && !isEmpty(sessionIds))
8083
builder.whereIn(this.column("session_id"), sessionIds);
8184

82-
const rows = await builder.then();
85+
if (events && !isEmpty(events))
86+
builder.whereIn(this.column("type"), events);
87+
88+
const rows = await builder.orderBy("created_at", "asc").then();
8389
return rows.map((row) => this.from(row));
8490
}
8591

packages/types/src/lesson.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ export type FindLessonsApiQuery = IFilter.SkippablePagination & {
145145

146146
export type FindUserLessonsApiResponse = Paginated<{
147147
lesson: Self;
148-
members: PopuldatedMember[];
148+
members: (PopuldatedMember & {
149+
attended: boolean;
150+
})[];
149151
}>;
150152

151153
export enum Duration {
@@ -168,5 +170,7 @@ export type LessonDays = LessonDay[];
168170

169171
export type FindLessonByIdApiResponse = {
170172
lesson: Self;
171-
members: PopuldatedMember[];
173+
members: (PopuldatedMember & {
174+
attended: boolean;
175+
})[];
172176
};

services/server/src/handlers/lesson.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
forbidden,
1717
notfound,
1818
} from "@/lib/error";
19-
import { ILesson, IUser, Wss } from "@litespace/types";
19+
import { ILesson, ISession, ISessionEvent, IUser, Wss } from "@litespace/types";
2020
import {
2121
lessons,
2222
users,
2323
knex,
2424
rooms,
2525
availabilitySlots,
2626
interviews,
27+
sessionEvents,
2728
} from "@litespace/models";
2829
import { Knex } from "knex";
2930
import safeRequest from "express-async-handler";
@@ -42,6 +43,7 @@ import { asSubSlots, canBook } from "@litespace/utils/availabilitySlots";
4243
import { isEmpty, isEqual } from "lodash";
4344
import { genSessionId } from "@litespace/utils";
4445
import { withImageUrls } from "@/lib/user";
46+
import { evaluateAttendance } from "@/lib/lesson";
4547

4648
const createLessonPayload = zod.object({
4749
tutorId: id,
@@ -275,6 +277,50 @@ async function findLessons(req: Request, res: Response, next: NextFunction) {
275277
await lessons.findLessonMembers(userLesonsIds)
276278
);
277279

280+
// Extract sessionIds and create a mapping of lessons to their respective members
281+
const lessonSessions = new Map<
282+
ISession.Id,
283+
{ userIds: number[]; duration: number }
284+
>();
285+
286+
userLessons.forEach((lesson) => {
287+
const members = lessonMembers.filter(
288+
(member) => member.lessonId === lesson.id
289+
);
290+
lessonSessions.set(lesson.sessionId, {
291+
userIds: members.map((m) => m.userId),
292+
duration: lesson.duration,
293+
});
294+
});
295+
296+
const sessionIds = Array.from(lessonSessions.keys());
297+
const events = await sessionEvents.find({
298+
sessionIds,
299+
users: lessonMembers.map((member) => member.userId),
300+
events: [
301+
ISessionEvent.EventType.UserJoined,
302+
ISessionEvent.EventType.UserLeft,
303+
],
304+
});
305+
306+
const eventsBySession = new Map<string, ISessionEvent.Self[]>();
307+
events.forEach((event) => {
308+
if (!eventsBySession.has(event.sessionId)) {
309+
eventsBySession.set(event.sessionId, []);
310+
}
311+
eventsBySession.get(event.sessionId)!.push(event);
312+
});
313+
314+
const attendanceBySession = new Map<string, Record<number, boolean>>();
315+
316+
lessonSessions.forEach(({ userIds, duration }, sessionId) => {
317+
const sessionEvents = eventsBySession.get(sessionId) || [];
318+
attendanceBySession.set(
319+
sessionId,
320+
evaluateAttendance({ events: sessionEvents, userIds, duration })
321+
);
322+
});
323+
278324
const result: ILesson.FindUserLessonsApiResponse = {
279325
list: userLessons.map((lesson) => {
280326
const members = lessonMembers
@@ -284,6 +330,9 @@ async function findLessons(req: Request, res: Response, next: NextFunction) {
284330
// mask private information
285331
phone: null,
286332
verifiedPhone: false,
333+
attended: !!attendanceBySession.get(lesson.sessionId)?.[
334+
member.userId
335+
],
287336
}));
288337
return { lesson, members };
289338
}),
@@ -308,13 +357,31 @@ async function findLessonById(req: Request, res: Response, next: NextFunction) {
308357
const isMember = !!members.find((member) => member.userId === user.id);
309358
if (!isMember) return next(forbidden());
310359

360+
const memberIds = members.map((member) => member.userId);
361+
362+
const events = await sessionEvents.find({
363+
sessionIds: [lesson.sessionId],
364+
users: memberIds,
365+
events: [
366+
ISessionEvent.EventType.UserJoined,
367+
ISessionEvent.EventType.UserLeft,
368+
],
369+
});
370+
371+
const attendance = evaluateAttendance({
372+
events,
373+
userIds: memberIds,
374+
duration: lesson.duration,
375+
});
376+
311377
const response: ILesson.FindLessonByIdApiResponse = {
312378
lesson,
313379
members: members.map((member) => ({
314380
...member,
315381
// mask private information
316382
phone: null,
317383
verifiedPhone: false,
384+
attended: attendance[member.userId],
318385
})),
319386
};
320387

services/server/src/lib/lesson.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import dayjs from "@/lib/dayjs";
2+
import { ISessionEvent } from "@litespace/types";
3+
4+
const MIN_ATTENDANCE_REQUIRED = 0.25;
5+
6+
export function evaluateAttendance({
7+
events,
8+
userIds,
9+
duration,
10+
}: {
11+
events: ISessionEvent.Self[];
12+
userIds: number[];
13+
duration: number;
14+
}): Record<number, boolean> {
15+
const attendanceStatus: Record<number, boolean> = Object.fromEntries(
16+
userIds.map((id) => [id, false])
17+
);
18+
19+
const userEventsMap = new Map<number, ISessionEvent.Self[]>();
20+
21+
events.forEach((event) => {
22+
if (userIds.includes(event.userId)) {
23+
if (!userEventsMap.has(event.userId)) userEventsMap.set(event.userId, []);
24+
25+
userEventsMap.get(event.userId)?.push(event);
26+
}
27+
});
28+
29+
userEventsMap.forEach((events, userId) => {
30+
let totalTime = 0;
31+
let lastJoinTime: number | null = null;
32+
33+
for (const event of events) {
34+
if (event.type === ISessionEvent.EventType.UserJoined) {
35+
if (lastJoinTime !== null) continue;
36+
37+
lastJoinTime = dayjs.utc(event.createdAt).valueOf();
38+
}
39+
40+
if (event.type === ISessionEvent.EventType.UserLeft) {
41+
if (lastJoinTime === null) continue;
42+
43+
const leftTime = dayjs.utc(event.createdAt).valueOf();
44+
totalTime += leftTime - lastJoinTime;
45+
lastJoinTime = null;
46+
}
47+
}
48+
49+
const minAttendanceMs = MIN_ATTENDANCE_REQUIRED * duration * 60 * 1000; // minimum attendance in ms
50+
51+
console.log({ minAttendanceMs, totalTime });
52+
53+
attendanceStatus[userId] = totalTime >= minAttendanceMs;
54+
});
55+
56+
return attendanceStatus;
57+
}

0 commit comments

Comments
 (0)