feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
+103
View File
@@ -1,10 +1,15 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import {
getCourseStats,
getDashOrgStats,
getOrgStudentLoginsByDayOfWeek,
getUserLoginStreak,
getRecentCertifications,
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries';
@@ -65,3 +70,101 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
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);
}
}