fix(dashboard): cache org stats in Redis with versioned invalidation (#883)

* fix(dashboard): cache org stats in Redis with versioned invalidation

Replace the never-expiring process-local cache in the org dashboard page
load with API-side Redis caching keyed per org. A version stamp prevents
slow reads from writing pre-purge data back into the cache.

Invalidate on course create/update/delete, student enroll/remove, certificate
claim, and progress reset. Remove the dashboard SSR module cache so every
page load reads through the API cache layer.

Fixes #852

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): address PR review on stats cache invalidation

- Validate bust via ZDashStats instead of raw query access
- Use import type for OrganisationAnalytics
- Resolve orgId before course delete so invalidation is reliable
- Invalidate immediately after persisted writes, before fallible side effects
- Invalidate on member role changes and program course access enrollment

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): complete stats cache invalidation coverage

Wire org stats cache purge on lesson completion (manual toggle and
video-watch auto-complete) so topCourses completion percentages stay
fresh. Harden bulk member adds with sequential inserts and a finally
invalidation boundary, and only purge org invite enrollments when new
group memberships were actually inserted.

Co-authored-by: Rotimi Best <[email protected]>

* fix(core): make lesson completion stats purge best-effort

Wrap org stats cache invalidation in try/catch so a lookup or Redis
failure cannot turn a successful lesson completion into a 500.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): keep org stats cache through lesson completion

Lesson complete is a high-frequency event and usually does not change
the rounded dashboard percentages. Rely on a 10 minute TTL instead of
purging the snapshot on every completion.

Co-authored-by: Rotimi Best <[email protected]>

* docs: explain how admin dashboard numbers are calculated

Add a user-facing guide covering headline counts, top courses,
certificates, login activity, and when the 10 minute snapshot refreshes.

Co-authored-by: Rotimi Best <[email protected]>

---------
This commit is contained in:
Rotimi Best
2026-08-16 22:15:34 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9fb75e11d2
commit 2ebb544a81
22 changed files with 558 additions and 57 deletions
@@ -8,6 +8,7 @@ import {
getCourseProgress as getCourseProgressQuery,
getCourseById,
getCourseWithRelations,
getOrgIdByCourseId,
updateCourse as updateCourseQuery,
updateLessonsSectionId
} from '@cio/db/queries/course';
@@ -45,6 +46,7 @@ import {
} from '@cio/db/queries/organization';
import { getStudentLimit } from '@cio/utils/plans';
import { env } from '../../config/env';
import { invalidateOrgStats } from '../../utils/redis/org-stats-cache';
import { annotateCourseContentWithProgression } from './progression';
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
import { guardCourseTypeTransition } from './public-course-guard';
@@ -119,6 +121,10 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
);
});
if (access.roleId === ROLE.STUDENT) {
await invalidateOrgStats(organizationId);
}
return true;
}
@@ -307,6 +313,8 @@ export async function createCourse(
};
});
await invalidateOrgStats(data.organizationId);
return result;
} catch (error) {
if (error instanceof AppError) {
@@ -420,6 +428,11 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
}
}
if (data.status !== undefined) {
const statsOrgId = await getOrgIdByCourseId(courseId);
await invalidateOrgStats(statsOrgId);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
@@ -440,10 +453,14 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
*/
export async function deleteCourse(courseId: string) {
try {
const statsOrgId = await getOrgIdByCourseId(courseId);
const deleted = await deleteCourseQuery(courseId);
if (!deleted) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
await invalidateOrgStats(statsOrgId);
return deleted;
} catch (error) {
if (error instanceof AppError) {
@@ -0,0 +1,108 @@
import { env } from '../../config/env';
import { getOrgIdsByGroupIds } from '@cio/db/queries/group';
import { logRedisUnavailableOnce, redis } from './redis';
/** TTL for org dashboard stats payload (10 min). */
export const ORG_STATS_CACHE_TTL_SECONDS = 600;
export function orgStatsKey(orgId: string): string {
return `dash:stats:v1:${orgId}`;
}
export function orgStatsVersionKey(orgId: string): string {
return `dash:stats:ver:${orgId}`;
}
type CachedOrgStatsPayload<T> = {
version: string;
data: T;
};
function parseCachedOrgStatsPayload<T>(raw: string): CachedOrgStatsPayload<T> | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (
typeof parsed !== 'object' ||
parsed === null ||
!('version' in parsed) ||
!('data' in parsed) ||
typeof (parsed as { version: unknown }).version !== 'string' ||
typeof (parsed as { data: unknown }).data !== 'object' ||
(parsed as { data: unknown }).data === null
) {
return null;
}
return parsed as CachedOrgStatsPayload<T>;
}
export async function readOrgStatsVersionAndCache<T>(orgId: string): Promise<{ version: string; data: T | null }> {
if (!env.REDIS_URL) {
return { version: '0', data: null };
}
try {
const versionKey = orgStatsVersionKey(orgId);
const payloadKey = orgStatsKey(orgId);
const [versionRaw, payloadRaw] = await redis.mGet([versionKey, payloadKey]);
const version = versionRaw ?? '0';
if (!payloadRaw) {
return { version, data: null };
}
const payload = parseCachedOrgStatsPayload<T>(payloadRaw);
if (!payload || payload.version !== version) {
return { version, data: null };
}
return { version, data: payload.data };
} catch (error) {
logRedisUnavailableOnce('Redis get failed for org stats cache, using database', error);
return { version: '0', data: null };
}
}
export async function writeOrgStatsCache<T>(orgId: string, version: string, value: T): Promise<void> {
if (!env.REDIS_URL) {
return;
}
try {
const payload: CachedOrgStatsPayload<T> = { version, data: value };
await redis.setEx(orgStatsKey(orgId), ORG_STATS_CACHE_TTL_SECONDS, JSON.stringify(payload));
} catch (error) {
logRedisUnavailableOnce('Redis set failed for org stats cache, continuing', error);
}
}
export async function invalidateOrgStats(orgId: string | null | undefined): Promise<void> {
if (!orgId || !env.REDIS_URL) {
return;
}
try {
await redis.incr(orgStatsVersionKey(orgId));
await redis.del(orgStatsKey(orgId));
} catch (error) {
logRedisUnavailableOnce('Redis invalidate failed for org stats cache, continuing', error);
}
}
export async function invalidateOrgStatsForIds(orgIds: string[]): Promise<void> {
const uniqueOrgIds = [...new Set(orgIds.filter((orgId) => Boolean(orgId)))];
for (const orgId of uniqueOrgIds) {
await invalidateOrgStats(orgId);
}
}
export async function invalidateOrgStatsForGroupIds(groupIds: string[]): Promise<void> {
const orgIds = await getOrgIdsByGroupIds(groupIds);
await invalidateOrgStatsForIds(orgIds);
}