2025-12-02 15:27:41 +01:00
|
|
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
2026-06-07 18:43:05 +01:00
|
|
|
import { env } from '@cio/core/config/env';
|
2026-04-12 08:58:41 +01:00
|
|
|
import {
|
|
|
|
|
getCourseStats,
|
|
|
|
|
getDashOrgStats,
|
2026-04-27 11:40:38 +01:00
|
|
|
getOrgStudentLoginsByDayOfWeek,
|
|
|
|
|
getUserLoginStreak,
|
2026-04-12 08:58:41 +01:00
|
|
|
getRecentCertifications,
|
|
|
|
|
getTotalCertificatesIssued
|
|
|
|
|
} from '@cio/db/queries/dash';
|
2026-04-27 11:40:38 +01:00
|
|
|
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
|
2026-06-07 18:43:05 +01:00
|
|
|
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
2025-12-02 15:27:41 +01:00
|
|
|
|
|
|
|
|
import { OrganisationAnalytics } from '@api/types';
|
2026-03-22 18:42:23 +01:00
|
|
|
import { getOrgIdBySiteName } from '@cio/db/queries';
|
2025-12-02 15:27:41 +01:00
|
|
|
|
|
|
|
|
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
|
|
|
|
const analytics: OrganisationAnalytics = {
|
2026-04-12 08:58:41 +01:00
|
|
|
totalCertificates: 0,
|
2025-12-02 15:27:41 +01:00
|
|
|
numberOfCourses: 0,
|
|
|
|
|
totalStudents: 0,
|
|
|
|
|
topCourses: [],
|
2026-04-12 08:58:41 +01:00
|
|
|
recentCertifications: []
|
2025-12-02 15:27:41 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-12 08:58:41 +01:00
|
|
|
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
|
2025-12-02 15:27:41 +01:00
|
|
|
getDashOrgStats(resolvedOrgId!),
|
|
|
|
|
getCourseStats(resolvedOrgId!),
|
2026-04-12 08:58:41 +01:00
|
|
|
getRecentCertifications(resolvedOrgId!),
|
|
|
|
|
getTotalCertificatesIssued(resolvedOrgId!)
|
2025-12-02 15:27:41 +01:00
|
|
|
]);
|
|
|
|
|
|
2026-04-12 08:58:41 +01:00
|
|
|
analytics.totalCertificates = certificateCountRows[0]?.count ?? 0;
|
2025-12-02 15:27:41 +01:00
|
|
|
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,
|
2026-04-12 08:58:41 +01:00
|
|
|
completion: c.completionPercentage,
|
|
|
|
|
certification: c.certificationPercentage
|
2025-12-02 15:27:41 +01:00
|
|
|
}));
|
|
|
|
|
|
2026-04-12 08:58:41 +01:00
|
|
|
analytics.recentCertifications = recentCertificationRows.map((row) => ({
|
|
|
|
|
id: row.profileId,
|
|
|
|
|
avatarUrl: row.avatarUrl,
|
|
|
|
|
name: row.fullname,
|
|
|
|
|
courseId: row.courseId,
|
|
|
|
|
course: row.courseTitle,
|
|
|
|
|
date: row.earnedAt ?? ''
|
2025-12-02 15:27:41 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-27 11:40:38 +01:00
|
|
|
|
|
|
|
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
|
|
|
|
|
|
|
|
|
type StudentLoginActivityRow = { day: string; count: number };
|
|
|
|
|
|
|
|
|
|
/** Redis TTL for login-activity chart payload (1 day). */
|
|
|
|
|
const LOGIN_ACTIVITY_CACHE_TTL_SECONDS = 86_400;
|
|
|
|
|
|
|
|
|
|
function parseLoginActivityCache(raw: string): StudentLoginActivityRow[] | null {
|
|
|
|
|
let parsed: unknown;
|
|
|
|
|
try {
|
|
|
|
|
parsed = JSON.parse(raw) as unknown;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(parsed) || parsed.length !== 7) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const out: StudentLoginActivityRow[] = [];
|
|
|
|
|
for (const item of parsed) {
|
|
|
|
|
if (
|
|
|
|
|
typeof item !== 'object' ||
|
|
|
|
|
item === null ||
|
|
|
|
|
!('day' in item) ||
|
|
|
|
|
!('count' in item) ||
|
|
|
|
|
typeof (item as { day: unknown }).day !== 'string' ||
|
|
|
|
|
typeof (item as { count: unknown }).count !== 'number' ||
|
|
|
|
|
!Number.isInteger((item as { count: number }).count) ||
|
|
|
|
|
(item as { count: number }).count < 0
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out.push({ day: (item as { day: string }).day, count: (item as { count: number }).count });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const label of DAY_LABELS) {
|
|
|
|
|
if (!out.some((r) => r.day === label)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Day-of-week login chart; results cached in Redis (24h TTL) when `REDIS_URL` is set. */
|
|
|
|
|
export async function getStudentLoginActivity(orgId: string, days: number): Promise<StudentLoginActivityRow[]> {
|
|
|
|
|
const cacheKey = dashLoginActivityKey(orgId, days);
|
|
|
|
|
|
|
|
|
|
if (env.REDIS_URL) {
|
|
|
|
|
try {
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
const rows = parseLoginActivityCache(cached);
|
|
|
|
|
if (rows) {
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logRedisUnavailableOnce('Redis get failed for login activity cache, using database', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const rows = await getOrgStudentLoginsByDayOfWeek(orgId, days);
|
|
|
|
|
|
|
|
|
|
const countByDow = new Map(rows.map((r) => [r.dayOfWeek, r.count]));
|
|
|
|
|
|
|
|
|
|
const result = DAY_LABELS.map((label, index) => ({
|
|
|
|
|
day: label,
|
|
|
|
|
count: countByDow.get(index) ?? 0
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (env.REDIS_URL) {
|
|
|
|
|
try {
|
|
|
|
|
await redis.setEx(cacheKey, LOGIN_ACTIVITY_CACHE_TTL_SECONDS, JSON.stringify(result));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logRedisUnavailableOnce('Redis set failed for login activity cache, continuing', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getStudentLoginActivity error:', error);
|
|
|
|
|
throw new AppError('Failed to load login activity', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getCurrentUserLoginStreak(userId: string) {
|
|
|
|
|
try {
|
|
|
|
|
return await getUserLoginStreak(userId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getCurrentUserLoginStreak error:', error);
|
|
|
|
|
throw new AppError('Failed to load login streak', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
|
|
|
|
}
|
|
|
|
|
}
|