feat: setup dashboard analytics api (#604)

* feat: setup dashboard analytics api

* chore: requested pr fixes

* fix: dash analytics server side type

* refactor: update Avatar component usage across the dashboard

- Replaced custom Avatar component with the new @cio/ui/base/avatar implementation for consistency.
- Enhanced avatar rendering with fallback options and improved accessibility.
- Cleaned up unused Avatar component files and related functions.
- Updated various components to utilize the new Avatar structure, ensuring a uniform appearance and behavior across the application.

* chore: remove console.log statements from analytics routes and API calls

- Eliminated console.log statements for orgId and siteName in the dash analytics router and DashStatApi class to clean up the code and improve performance.

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Adeyemi Adetunji
2025-12-02 15:27:41 +01:00
committed by GitHub
co-authored by Rotimi Best
parent c7854efae3
commit 9a5aee1ee0
40 changed files with 573 additions and 276 deletions
+81
View File
@@ -0,0 +1,81 @@
import {
and,
course,
dashOrgStats,
db,
desc,
eq,
group,
groupmember,
lesson,
lessonCompletion,
organization,
profile,
sql
} from '@db/drizzle';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
courseId: course.id,
courseTitle: course.title,
totalStudents: sql<number>`COUNT(DISTINCT ${groupmember.id})`.as('total_students'),
totalLessons: sql<number>`COUNT(DISTINCT ${lesson.id})`.as('total_lessons'),
completedLessons: sql<number>`
COUNT(DISTINCT ${lessonCompletion.id})
FILTER (WHERE ${lessonCompletion.isComplete} = true)
`.as('completed_lessons')
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.leftJoin(lesson, eq(lesson.courseId, course.id))
.leftJoin(
lessonCompletion,
and(eq(lessonCompletion.lessonId, lesson.id), eq(lessonCompletion.profileId, groupmember.profileId))
)
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(course.id, course.title)
.orderBy(sql`completed_lessons DESC`)
.as('course_stats');
return db
.select({
courseId: subquery.courseId,
courseTitle: subquery.courseTitle,
totalStudents: sql<number>`${subquery.totalStudents}::integer`,
completionPercentage: sql<number>`
CASE
WHEN ${subquery.totalStudents} * ${subquery.totalLessons} = 0 THEN 0
ELSE ROUND((${subquery.completedLessons}::numeric / (${subquery.totalStudents} * ${subquery.totalLessons})) * 100)::integer
END
`
})
.from(subquery)
.orderBy(subquery.completedLessons)
.limit(5);
}
export function getRecentEnrollments(orgId: string) {
return db
.select({
profileId: profile.id,
avatarUrl: profile.avatarUrl,
fullname: profile.fullname,
courseId: course.id,
courseTitle: course.title,
enrolledAt: groupmember.createdAt
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.innerJoin(profile, eq(profile.id, groupmember.profileId))
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(profile.id, course.id, course.title, groupmember.createdAt)
.orderBy(desc(groupmember.createdAt))
.limit(5);
}
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
+1
View File
@@ -0,0 +1 @@
export * from './dash';
+19
View File
@@ -0,0 +1,19 @@
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: {
id: string;
title: string;
enrollments: number;
completion: number;
}[];
enrollments: {
id: string;
avatarUrl: string;
name: string;
courseId: string;
course: string;
date: string;
}[];
}
@@ -13,6 +13,10 @@ import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export function getOrgIdBySiteName(siteName: string) {
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
}
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db
.select({
+2
View File
@@ -137,3 +137,5 @@ export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
export type TLocale = (typeof schema.locale.enumValues)[number];
export type TPlan = (typeof schema.plan.enumValues)[number];
export type TDashStats = typeof schema.dashOrgStats.$inferSelect;
@@ -0,0 +1 @@
export * from './stats';
@@ -0,0 +1,12 @@
import * as z from 'zod';
export const ZDashStats = z
.object({
orgId: z.string().optional(),
siteName: z.string().optional()
})
.refine((v) => !!(v.orgId || v.siteName), {
message: 'Either orgId or siteName is required'
});
export type TDashStats = z.infer<typeof ZDashStats>;
+1
View File
@@ -6,3 +6,4 @@ export * from './mail';
export * from './media';
export * from './onboarding';
export * from './organization';
export * from './dash';