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 <best@classroomio.com>
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
+12 -9
View File
@@ -1,20 +1,22 @@
import 'dotenv/config';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { secureHeaders } from 'hono/secure-headers';
import { auth } from '@cio/db/auth';
import { Hono } from '@api/utils/hono';
import rateLimiter from '@api/middlewares/rate-limiter';
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
import { Hono } from '@api/utils/hono';
import { accountRouter } from '@api/routes/account';
import { auth } from '@cio/db/auth';
import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
// --- routes
import { mailRouter } from '@api/routes/mail';
import { courseRouter } from '@api/routes/course';
import { accountRouter } from '@api/routes/account';
import { dashAnalyticsRouter } from '@api/routes/dash';
import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
export const app = new Hono()
@@ -74,6 +76,7 @@ export const app = new Hono()
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/organization', organizationRouter)
.route('/dash', dashAnalyticsRouter)
// Error handling
.onError((err, c) => {
+1
View File
@@ -0,0 +1 @@
export * from './stats';
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().post(
'/stats',
authMiddleware,
zValidator('json', ZDashStats),
async (c) => {
try {
const { orgId, siteName } = c.req.valid('json');
const result = await getOrganisationAnalytics(orgId, siteName);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
}
);
+60
View File
@@ -0,0 +1,60 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = {
revenue: 0,
numberOfCourses: 0,
totalStudents: 0,
topCourses: [],
enrollments: []
};
let resolvedOrgId = orgId;
if (!resolvedOrgId && siteName) {
const [org] = await getOrgIdBySiteName(siteName);
if (org) {
resolvedOrgId = org.id;
} else {
throw new AppError('Organization not found for the given site name', ErrorCodes.ORG_NOT_FOUND, 404);
}
}
try {
const [stats, topCourses, enrollments] = await Promise.all([
getDashOrgStats(resolvedOrgId!),
getCourseStats(resolvedOrgId!),
getRecentEnrollments(resolvedOrgId!)
]);
analytics.revenue = 0;
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
analytics.topCourses = topCourses.map((c) => ({
id: c.courseId,
title: c.courseTitle,
enrollments: c.totalStudents,
completion: c.completionPercentage
}));
analytics.enrollments = enrollments.map((e) => ({
id: e.profileId,
avatarUrl: e.avatarUrl,
name: e.fullname,
courseId: e.courseId,
course: e.courseTitle,
date: e.enrolledAt
}));
return analytics;
} catch (error) {
console.error('Failed to load organisation analytics:', error);
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './account';
export * from './media';
export * from './onboarding';
export * from './dash';
+23
View File
@@ -0,0 +1,23 @@
export interface TopCourse {
id: string;
title: string;
enrollments: number;
completion: number;
}
export interface Enrollment {
id: string;
avatarUrl: string | null;
name: string;
courseId: string;
course: string;
date: string;
}
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: TopCourse[];
enrollments: Enrollment[];
}
+1
View File
@@ -1,2 +1,3 @@
// Re-export all types
export * from './database';
export * from './dash';
+4
View File
@@ -28,6 +28,10 @@ export interface ErrorResponse {
// Error codes constants for consistency
export const ErrorCodes = {
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',