Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/headless/src/lessons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export function useFindLessons({
...query
}: ILesson.FindLessonsApiQuery & { userOnly?: boolean }): UsePaginateResult<{
lesson: ILesson.Self;
members: ILesson.PopuldatedMember[];
members: (ILesson.PopuldatedMember & {
attended: boolean;
})[];
Comment on lines +15 to +17
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining the type this way is confusing.

}> {
const api = useApi();

Expand Down
12 changes: 9 additions & 3 deletions packages/models/src/sessionEvents.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -12,6 +12,7 @@ export class SessionEvents {
tx?: Knex.Transaction
): Promise<ISessionEvent.Self> {
const now = dayjs.utc().toDate();

const rows = await this.builder(tx)
.insert({
type: event.type,
Expand Down Expand Up @@ -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<ISessionEvent.Self[]> {
const builder = this.builder(tx).select("*");

Expand All @@ -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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Don't use the column names direcly. Make use of the this.column util.
  • Also, order by the id as well incase two rows were created at the same time. Here is an example.

return rows.map((row) => this.from(row));
}

Expand Down
8 changes: 6 additions & 2 deletions packages/types/src/lesson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ export type FindLessonsApiQuery = IFilter.SkippablePagination & {

export type FindUserLessonsApiResponse = Paginated<{
lesson: Self;
members: PopuldatedMember[];
members: (PopuldatedMember & {
attended: boolean;
})[];
Comment on lines +148 to +150
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definding the type this way is confusing.

}>;

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

export type FindLessonByIdApiResponse = {
lesson: Self;
members: PopuldatedMember[];
members: (PopuldatedMember & {
attended: boolean;
})[];
Comment on lines +173 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the type.

};
69 changes: 68 additions & 1 deletion services/server/src/handlers/lesson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ 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,
knex,
rooms,
availabilitySlots,
interviews,
sessionEvents,
} from "@litespace/models";
import { Knex } from "knex";
import safeRequest from "express-async-handler";
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, ISessionEvent.Self[]>();
events.forEach((event) => {
if (!eventsBySession.has(event.sessionId)) {
eventsBySession.set(event.sessionId, []);
}
eventsBySession.get(event.sessionId)!.push(event);
});

const attendanceBySession = new Map<string, Record<number, boolean>>();

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
Expand All @@ -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 };
}),
Expand All @@ -308,13 +357,31 @@ 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) => ({
...member,
// mask private information
phone: null,
verifiedPhone: false,
attended: attendance[member.userId],
})),
};

Expand Down
57 changes: 57 additions & 0 deletions services/server/src/lib/lesson.ts
Original file line number Diff line number Diff line change
@@ -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<number, boolean> {
const attendanceStatus: Record<number, boolean> = Object.fromEntries(
userIds.map((id) => [id, false])
);

const userEventsMap = new Map<number, ISessionEvent.Self[]>();

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 });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left over.


attendanceStatus[userId] = totalTime >= minAttendanceMs;
});

return attendanceStatus;
}
Loading