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
+21
View File
@@ -1167,6 +1167,27 @@ export const getExploreCourses = async ({
}
};
/**
* Resolves the organization ID for a course via its group.
*/
export async function getOrgIdByCourseId(courseId: string): Promise<string | null> {
try {
const [row] = await db
.select({ orgId: schema.group.organizationId })
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(eq(schema.course.id, courseId))
.limit(1);
return row?.orgId ?? null;
} catch (error) {
console.error('getOrgIdByCourseId error:', error);
throw new Error(
`Failed to get organization ID for course: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets course with organization data (title, org name, org siteName, groupId)
* @param courseId Course ID
+20
View File
@@ -328,3 +328,23 @@ export async function insertGroupMembersOnConflictDoNothing(
throw new Error(`Failed to insert group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getOrgIdsByGroupIds(groupIds: string[]): Promise<string[]> {
if (groupIds.length === 0) {
return [];
}
try {
const rows = await db
.selectDistinct({ orgId: schema.group.organizationId })
.from(schema.group)
.where(inArray(schema.group.id, groupIds));
return rows.map((row) => row.orgId).filter((orgId): orgId is string => Boolean(orgId));
} catch (error) {
console.error('getOrgIdsByGroupIds error:', error);
throw new Error(
`Failed to get organization IDs for groups: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}