Skip to content

Commit

Permalink
Feat/detailed event stats (#198)
Browse files Browse the repository at this point in the history
* feat: update event end time

* remove: draft query

* feat: endpoint to get all participant

* feat: count registered participant attendance

* refactor: count guest and registered participant

* feat: update db scrips, and add dbstudio

* refactor: convert date string into date during parse

* feat: update stats endpoint, with the attendance data

* Add migration confirmation prompt and log migration details

* fix: query should filter guest participant

* feat: update stats response

* update type naming and add type for getparticipant endpoint

* feat: add isJoined and joinDuration to participtans endpoint
  • Loading branch information
ghaniswara authored Mar 28, 2024
1 parent bdce04e commit 92a6057
Show file tree
Hide file tree
Showing 17 changed files with 613 additions and 108 deletions.
2 changes: 1 addition & 1 deletion app/(pages)/events/[eventID]/detail/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default async function Page({ params: { eventID } }: PageProps) {

const token = cookies().get('token')?.value ?? '';

const registereesResponse: EventType.RegistereeParticipantResponse =
const registereesResponse: EventType.GetRegistereeResponse =
await InternalApiFetcher.get(`/api/events/${eventID}/details/registeree`, {
headers: {
Cookie: `token=${token}`,
Expand Down
39 changes: 29 additions & 10 deletions app/(server)/_features/activity-log/repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { db } from '@/(server)/_shared/database/database';
import { activitiesLog, type InsertActivityLog } from './schema';
import { RoomType } from '@/_shared/types/room';
import { count, countDistinct, sql, type SQL } from 'drizzle-orm';
import { countDistinct, eq, sql, type SQL, isNull, and } from 'drizzle-orm';
import { participant } from '../event/schema';

export const addLog = async (data: InsertActivityLog) => {
const res = await db.insert(activitiesLog).values(data).returning();
Expand Down Expand Up @@ -42,24 +43,42 @@ export const aggregateRoomDuration = async (
}
};

export const countJoinedUser = async (roomID: string) => {
export const countRegisteredParticipant = async (roomID: string) => {
const res = await db
.select({ value: countDistinct(activitiesLog.createdBy) })
.select({ value: countDistinct(sql`${activitiesLog.meta} ->> 'clientID'`) })
.from(activitiesLog)
.where(
sql`${activitiesLog.meta} ->> 'roomID' = ${roomID} AND ${activitiesLog.createdBy} IS NOT NULL`
.innerJoin(
participant,
and(
eq(sql`${activitiesLog.meta} ->> 'clientID'`, participant.clientId),
eq(sql`${activitiesLog.meta} ->> 'roomID'`, roomID)
)
);

return res[0];
};

export const countJoinedGuest = async (roomID: string) => {
export const countGuestParticipant = async (roomID: string) => {
const res = await db
.select({ value: count() })
.select({ value: countDistinct(sql`${activitiesLog.meta} ->> 'clientID'`) })
.from(activitiesLog)
.where(
sql`${activitiesLog.meta} ->> 'roomID' = ${roomID} AND ${activitiesLog.createdBy} IS NULL`
);
.fullJoin(
participant,
and(
eq(sql`${activitiesLog.meta} ->> 'clientID'`, participant.clientId),
eq(sql`${activitiesLog.meta} ->> 'roomID'`, roomID)
)
)
.where(isNull(participant.clientId));

return res[0];
};

export const countParticipants = async (roomID: string) => {
const res = await db
.select({ value: countDistinct(sql`${activitiesLog.meta} ->> 'clientID'`) })
.from(activitiesLog)
.where(sql`${activitiesLog.meta} ->> 'roomID' = ${roomID}`);

return res[0];
};
218 changes: 211 additions & 7 deletions app/(server)/_features/event/repository.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
/* eslint-disable prettier/prettier */
import { db } from '@/(server)/_shared/database/database';
import { iEventRepo } from './service';
import { Participant, iEventRepo } from './service';
import {
events,
insertEvent,
insertParticipant,
participant,
participant as participants,
selectEvent,
} from './schema';
import { DBQueryConfig, SQL, and, count, eq, isNull, sql } from 'drizzle-orm';
import { PageMeta } from '@/_shared/types/types';
import { User, users } from '../user/schema';
import { activitiesLog } from '../activity-log/schema';
import { z } from 'zod';
import { ArrayRoomDurationMeta, RoomDurationMeta } from '@/(server)/api/user/activity/route';

type participantAttendances = {
participant: {
clientID: string;
joinDuration: number;
isAttended: boolean;
}[],
attendedCount: number;
totalCount: number;
eventDuration: number;
}



export class EventRepo implements iEventRepo {
async addEvent(event: insertEvent) {
Expand Down Expand Up @@ -133,9 +151,8 @@ export class EventRepo implements iEventRepo {

if (isStartAfter && isStartBefore) {
whereQuery.push(
sql`${
events.startTime
} BETWEEN ${isStartAfter.toISOString()} AND ${isStartBefore.toISOString()}`
sql`${events.startTime
} BETWEEN ${isStartAfter.toISOString()} AND ${isStartBefore.toISOString()}`
);
} else if (isStartAfter) {
whereQuery.push(
Expand All @@ -149,9 +166,8 @@ export class EventRepo implements iEventRepo {

if (isEndAfter && isEndBefore) {
whereQuery.push(
sql`${
events.endTime
} BETWEEN ${isEndAfter.toISOString()} AND ${isEndBefore.toISOString()}`
sql`${events.endTime
} BETWEEN ${isEndAfter.toISOString()} AND ${isEndBefore.toISOString()}`
);
} else if (isEndAfter) {
whereQuery.push(sql`${events.endTime} >= ${isEndAfter.toISOString()}`);
Expand Down Expand Up @@ -374,4 +390,192 @@ export class EventRepo implements iEventRepo {

return res;
}

async getAllParticipantsByEventId(
eventId: number,
limit: number,
page: number
): Promise<{
data: Participant[];
meta: PageMeta;
} | undefined> {

if (page < 1) {
page = 1;
}

if (limit < 1) {
limit = 10;
}

const subQueryConnectedClient = db
.select()
.from(activitiesLog)
.innerJoin(
events,
eq(events.roomId, sql`${activitiesLog.meta} ->> 'roomID'`)
)
.where(eq(events.id, eventId))
.as('ConnectedClientsLog');

// Removes the duplicates clientID
const subQueryUniqueConnectedClient = db
.selectDistinctOn([sql`${subQueryConnectedClient.activities_logs.meta} ->> 'clientID'`], {
clientID:
sql<string>`${subQueryConnectedClient.activities_logs.meta} ->> 'clientID'`.as(
'clientID'
),
name: sql<string>`${subQueryConnectedClient.activities_logs.meta} ->> 'name'`.as(
'name'
),
})
.from(subQueryConnectedClient)
.as('UniqueConnectedClients');

// TODO : Query the alias for same clientID with same name
// subQueryGetAlias

// add the isRegistered and isJoined field and email
const finalQuery = await db
.select({
clientID: subQueryUniqueConnectedClient.clientID,
name: sql<string>`
CASE
WHEN ${participants.firstName} IS NULL THEN ${subQueryUniqueConnectedClient.name}
ELSE CONCAT_WS(' ', ${participants.firstName}, ${participants.lastName})
END
`.as('name'),
email: participants.email,
isRegistered: sql<boolean>`
CASE
WHEN ${participants.clientId} IS NULL THEN ${false}
ELSE ${true}
END
`.as('isRegistered'),
isJoined: sql<boolean>`
CASE
WHEN (${participants.clientId} IS NOT NULL AND ${subQueryUniqueConnectedClient.clientID} IS NOT NULL)
OR (${participants.clientId} IS NULL AND ${subQueryUniqueConnectedClient.clientID} IS NOT NULL) THEN ${true}
ELSE ${false}
END
`.as('isJoined'),
})
.from(subQueryUniqueConnectedClient)
.fullJoin(
participants,
eq(participants.clientId, subQueryUniqueConnectedClient.clientID)
).as('participants')

const { data, meta } = await db.transaction(async (tx) => {
const data = await tx.select().from(finalQuery)
.limit(limit)
.offset((page - 1) * limit);

const total = await tx.select({ total: count() }).from(finalQuery);

const meta: PageMeta = {
current_page: page,
total_page: Math.ceil(total[0].total / limit) || 1,
per_page: limit,
total_record: total[0].total
}

return { data, meta };
})

return { data, meta };
}

async getParticipantAttendancePercentage(eventId: number): Promise<participantAttendances> {
const { participants, event } = await db.transaction(async (tx) => {
const event = await tx.query.events.findFirst({
where: eq(events.id, eventId)
})

if (!event) {
throw new Error('Event not found');
}

const registeredParticipants = await tx
.select({
clientID: sql<string>`${activitiesLog.meta} ->> 'clientID'`.as('clientID'),
combined_logs: sql<z.infer<typeof RoomDurationMeta>[]>`ARRAY_AGG(meta ORDER BY ${activitiesLog.meta} ->> 'joinTime')`.as('combined_logs')
})
.from(activitiesLog)
.innerJoin(
participant,
and(
eq(sql<object[]>`${activitiesLog.meta} ->> 'roomID'`, event.roomId),
eq(sql<string>`${activitiesLog.meta} ->> 'clientID'`, sql<string>`${participant.clientId}`)))
.groupBy(sql<string>`${activitiesLog.meta} ->> 'clientID'`);
return { participants: registeredParticipants, event };
})

const eventDuration = (event.endTime.getTime() - event.startTime.getTime())/1000;

const participantAttendance = participants.map((participant) => {
const parsedLogs = ArrayRoomDurationMeta.parse(participant.combined_logs)
const totalDuration = getTotalJoinDuration(parsedLogs, event.endTime)/1000;
const isAttended = ((totalDuration / eventDuration) * 100) > 80;
return {
clientID: participant.clientID,
joinDuration: totalDuration,
isAttended,
}
})

const attendedCount = participantAttendance.filter((participant) => participant.isAttended).length;
const totalCount = participantAttendance.length;

return {
participant: participantAttendance,
attendedCount,
totalCount,
eventDuration
}

}

}


function getTotalJoinDuration(intervals: z.infer<typeof RoomDurationMeta>[], sessionEndTime: Date): number {
// Sort intervals by joinTime
intervals.sort((a, b) => a.joinTime.getTime() - b.joinTime.getTime());

let totalDuration = 0;
let currentEndTime: number | null = null;

for (const interval of intervals) {
// Check if interval is within session end time
const validJoinTime = interval.joinTime <= sessionEndTime;
const validLeaveTime = interval.leaveTime <= sessionEndTime;

if (validJoinTime && validLeaveTime) {
if (currentEndTime === null || interval.joinTime.getTime() >= currentEndTime) {
// No overlap, add duration
totalDuration += interval.duration;
} else if (interval.leaveTime.getTime() > (currentEndTime || 0)) {
// Overlap, add only the additional duration beyond the current end time
totalDuration += interval.leaveTime.getTime() - (currentEndTime || 0);
}
// Update currentEndTime
currentEndTime = Math.max(currentEndTime || 0, interval.leaveTime.getTime());
} else if (validJoinTime && !validLeaveTime) {
// Partial overlap
if (currentEndTime === null || interval.joinTime.getTime() >= currentEndTime) {
// No overlap, add duration until session end time
totalDuration += sessionEndTime.getTime() - interval.joinTime.getTime();
} else {
// Overlap, add only the additional duration until session end time
totalDuration += sessionEndTime.getTime() - (currentEndTime || 0);
}
// No need to process further intervals if we've reached session end time
break;
}
// Ignore intervals outside session end time
}

return totalDuration;
}

31 changes: 31 additions & 0 deletions app/(server)/_features/event/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ import {
SendEventCancelledEmail,
SendEventRescheduledEmail,
} from '@/(server)/_shared/mailer/mailer';
import { PageMeta } from '@/_shared/types/types';

/**
* Type used to represent all type of participant in an event
*
* This means a registered participants , that joined or not and
* guest participant
*
* it's used for the the event details page
* https://www.figma.com/proto/wjeG4AE78OXVZNxjWl09yn/inlive-room?node-id=1411-4390&t=X9WZ14pNuG2M6rKG-0&page-id=214%3A591&starting-point-node-id=245%3A519
*/
export type Participant = {
clientID: string;
name: string;
email?: string | null;
isRegistered: boolean;
isJoined: boolean;
isAttended?: boolean;
joinDuration?: number;
};

export interface iEventRepo {
addEvent(eventData: insertEvent): Promise<selectEvent>;
Expand Down Expand Up @@ -40,6 +60,17 @@ export interface iEventRepo {
): Promise<selectParticipant[] | undefined>;
getParticipantById(id: number): Promise<selectParticipant | undefined>;
getEventHostByEventId(eventId: number): Promise<selectUser | undefined>;
getAllParticipantsByEventId(
eventId: number,
limit: number,
page: number
): Promise<
| {
data: Participant[];
meta: PageMeta;
}
| undefined
>;
}

export interface EventParticipant {
Expand Down
Loading

0 comments on commit 92a6057

Please sign in to comment.