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:
co-authored by
Rotimi Best
parent
9fb75e11d2
commit
2ebb544a81
@@ -0,0 +1,133 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const redisMocks = vi.hoisted(() => ({
|
||||||
|
mGet: vi.fn(),
|
||||||
|
setEx: vi.fn(),
|
||||||
|
incr: vi.fn(),
|
||||||
|
del: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/core/config/env', () => ({
|
||||||
|
env: { REDIS_URL: 'redis://localhost:6379' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/core/utils/redis/redis', () => ({
|
||||||
|
redis: redisMocks,
|
||||||
|
logRedisUnavailableOnce: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/db/queries/dash', () => ({
|
||||||
|
getDashOrgStats: vi.fn(),
|
||||||
|
getCourseStats: vi.fn(),
|
||||||
|
getRecentCertifications: vi.fn(),
|
||||||
|
getTotalCertificatesIssued: vi.fn(),
|
||||||
|
getOrgStudentLoginsByDayOfWeek: vi.fn(),
|
||||||
|
getUserLoginStreak: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/db/queries', () => ({
|
||||||
|
getOrgIdBySiteName: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCourseStats,
|
||||||
|
getDashOrgStats,
|
||||||
|
getRecentCertifications,
|
||||||
|
getTotalCertificatesIssued
|
||||||
|
} from '@cio/db/queries/dash';
|
||||||
|
import { getOrganisationAnalytics } from '@api/services/dash';
|
||||||
|
|
||||||
|
const sampleAnalytics = {
|
||||||
|
totalCertificates: 2,
|
||||||
|
numberOfCourses: 8,
|
||||||
|
totalStudents: 10,
|
||||||
|
topCourses: [],
|
||||||
|
recentCertifications: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getOrganisationAnalytics cache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
redisMocks.mGet.mockResolvedValue([null, null]);
|
||||||
|
vi.mocked(getDashOrgStats).mockResolvedValue([{ orgId: 'org-1', noOfCourses: 8, enrolledStudents: 10 }]);
|
||||||
|
vi.mocked(getCourseStats).mockResolvedValue([]);
|
||||||
|
vi.mocked(getRecentCertifications).mockResolvedValue([]);
|
||||||
|
vi.mocked(getTotalCertificatesIssued).mockResolvedValue([{ count: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes from the database on cache miss and writes the payload', async () => {
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
expect(getDashOrgStats).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redisMocks.setEx).toHaveBeenCalledWith(
|
||||||
|
'dash:stats:v1:org-1',
|
||||||
|
600,
|
||||||
|
JSON.stringify({ version: '0', data: sampleAnalytics })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached payload when version matches', async () => {
|
||||||
|
redisMocks.mGet.mockResolvedValue([
|
||||||
|
'3',
|
||||||
|
JSON.stringify({ version: '3', data: { ...sampleAnalytics, numberOfCourses: 5 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(5);
|
||||||
|
expect(getDashOrgStats).not.toHaveBeenCalled();
|
||||||
|
expect(redisMocks.setEx).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a version mismatch as a cache miss', async () => {
|
||||||
|
redisMocks.mGet.mockResolvedValue([
|
||||||
|
'4',
|
||||||
|
JSON.stringify({ version: '3', data: { ...sampleAnalytics, numberOfCourses: 5 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
expect(getDashOrgStats).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips cache read when bustCache is true but still writes', async () => {
|
||||||
|
redisMocks.mGet.mockResolvedValue([
|
||||||
|
'1',
|
||||||
|
JSON.stringify({ version: '1', data: { ...sampleAnalytics, numberOfCourses: 5 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1', undefined, true);
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
expect(getDashOrgStats).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redisMocks.setEx).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the database when redis read fails', async () => {
|
||||||
|
redisMocks.mGet.mockRejectedValue(new Error('redis down'));
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
expect(getDashOrgStats).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns live data when redis write fails', async () => {
|
||||||
|
redisMocks.setEx.mockRejectedValue(new Error('redis down'));
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats malformed cache payload as a miss', async () => {
|
||||||
|
redisMocks.mGet.mockResolvedValue(['1', '{not-json']);
|
||||||
|
|
||||||
|
const result = await getOrganisationAnalytics('org-1');
|
||||||
|
|
||||||
|
expect(result.numberOfCourses).toBe(8);
|
||||||
|
expect(getDashOrgStats).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,9 +41,9 @@ export const dashAnalyticsRouter = new Hono()
|
|||||||
})
|
})
|
||||||
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
|
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
|
||||||
try {
|
try {
|
||||||
const { orgId, siteName } = c.req.valid('query');
|
const { orgId, siteName, bust } = c.req.valid('query');
|
||||||
|
|
||||||
const result = await getOrganisationAnalytics(orgId, siteName);
|
const result = await getOrganisationAnalytics(orgId, siteName, bust);
|
||||||
|
|
||||||
return c.json({ success: true, data: result }, 200);
|
return c.json({ success: true, data: result }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@db/queries';
|
} from '@db/queries';
|
||||||
|
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
import { QUESTION_TYPE_IDS } from '@cio/question-types';
|
import { QUESTION_TYPE_IDS } from '@cio/question-types';
|
||||||
|
|
||||||
async function cloneLessonLanguages(newLessons: TLesson[], oldLessons: TLesson[]): Promise<void> {
|
async function cloneLessonLanguages(newLessons: TLesson[], oldLessons: TLesson[]): Promise<void> {
|
||||||
@@ -237,6 +238,9 @@ export async function cloneCourse(
|
|||||||
type: course.type
|
type: course.type
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statsOrgId = organizationId ?? newGroup.organizationId;
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
|
||||||
// 4. add group member
|
// 4. add group member
|
||||||
await addGroupMember({
|
await addGroupMember({
|
||||||
profileId: userId,
|
profileId: userId,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { claimMemberCertificateEarned } from '@cio/db/queries/course/people';
|
|||||||
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
||||||
import { PLAN } from '@cio/utils/plans';
|
import { PLAN } from '@cio/utils/plans';
|
||||||
import { env } from '@cio/core/config/env';
|
import { env } from '@cio/core/config/env';
|
||||||
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||||
|
|
||||||
export type CertificationBlocker = {
|
export type CertificationBlocker = {
|
||||||
@@ -238,6 +239,8 @@ export async function evaluateCourseCertification(
|
|||||||
if (didClaimCertificate) {
|
if (didClaimCertificate) {
|
||||||
isNewCompletion = true;
|
isNewCompletion = true;
|
||||||
|
|
||||||
|
await invalidateOrgStats(courseRow.orgId);
|
||||||
|
|
||||||
scheduleCertificationCompletionWork({
|
scheduleCertificationCompletionWork({
|
||||||
courseId,
|
courseId,
|
||||||
profileId,
|
profileId,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
|
|||||||
import type { TNewCourseInviteAudit } from '@db/types';
|
import type { TNewCourseInviteAudit } from '@db/types';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email';
|
import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email';
|
||||||
@@ -693,6 +694,8 @@ export async function enrollInCourse(
|
|||||||
email: normalizedEmail
|
email: normalizedEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await invalidateOrgStats(org.id);
|
||||||
|
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||||
|
|
||||||
await sendStudentJoinEmails({
|
await sendStudentJoinEmails({
|
||||||
@@ -1065,6 +1068,10 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result.alreadyJoined) {
|
||||||
|
await invalidateOrgStats(result.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles([result.courseId], [user.id]);
|
await ensureComplianceEnrollmentRecordsForProfiles([result.courseId], [user.id]);
|
||||||
|
|
||||||
if (!result.alreadyJoined) {
|
if (!result.alreadyJoined) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progres
|
|||||||
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
||||||
import type { TGroupmember } from '@cio/db/types';
|
import type { TGroupmember } from '@cio/db/types';
|
||||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
|
import { getCourseWithOrgData, getOrgIdByCourseId } from '@cio/db/queries/course';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
@@ -57,6 +58,11 @@ export async function addMember(
|
|||||||
|
|
||||||
const addedMember = await addCourseMember(courseId, data);
|
const addedMember = await addCourseMember(courseId, data);
|
||||||
|
|
||||||
|
if (data.roleId === ROLE.STUDENT) {
|
||||||
|
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.roleId === ROLE.STUDENT && data.profileId) {
|
if (data.roleId === ROLE.STUDENT && data.profileId) {
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]);
|
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]);
|
||||||
}
|
}
|
||||||
@@ -201,8 +207,25 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
|||||||
theme: courseOrgData.orgTheme
|
theme: courseOrgData.orgTheme
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add all members
|
// Add members sequentially so partial failures still invalidate stats for successful inserts.
|
||||||
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
|
const addedMembers = [];
|
||||||
|
let addedStudentMember = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const member of members) {
|
||||||
|
const addedMember = await addCourseMember(courseId, member);
|
||||||
|
addedMembers.push(addedMember);
|
||||||
|
|
||||||
|
if (member.roleId === ROLE.STUDENT) {
|
||||||
|
addedStudentMember = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (addedStudentMember) {
|
||||||
|
await invalidateOrgStats(courseOrgData.orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const studentProfileIds = members
|
const studentProfileIds = members
|
||||||
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
||||||
.map((member) => member.profileId!);
|
.map((member) => member.profileId!);
|
||||||
@@ -279,10 +302,26 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
|||||||
*/
|
*/
|
||||||
export async function updateMember(courseId: string, memberId: string, data: Partial<TGroupmember>) {
|
export async function updateMember(courseId: string, memberId: string, data: Partial<TGroupmember>) {
|
||||||
try {
|
try {
|
||||||
|
const existingMember = await getCourseMember(courseId, memberId);
|
||||||
|
if (!existingMember) {
|
||||||
|
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await updateCourseMember(courseId, memberId, data);
|
const updated = await updateCourseMember(courseId, memberId, data);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousRoleId = existingMember.roleId;
|
||||||
|
const nextRoleId = data.roleId ?? previousRoleId;
|
||||||
|
const studentRoleChanged =
|
||||||
|
data.roleId !== undefined && (previousRoleId === ROLE.STUDENT || nextRoleId === ROLE.STUDENT);
|
||||||
|
|
||||||
|
if (studentRoleChanged) {
|
||||||
|
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
@@ -308,6 +347,12 @@ export async function deleteMember(courseId: string, memberId: string) {
|
|||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleted.roleId === ROLE.STUDENT) {
|
||||||
|
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
return deleted;
|
return deleted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
@@ -348,6 +393,9 @@ export async function resetMemberCourseProgress(courseId: string, memberId: stri
|
|||||||
summary
|
summary
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -10,19 +10,52 @@ import {
|
|||||||
} from '@cio/db/queries/dash';
|
} from '@cio/db/queries/dash';
|
||||||
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
|
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
|
||||||
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
||||||
|
import {
|
||||||
|
invalidateOrgStats,
|
||||||
|
readOrgStatsVersionAndCache,
|
||||||
|
writeOrgStatsCache
|
||||||
|
} from '@cio/core/utils/redis/org-stats-cache';
|
||||||
|
|
||||||
import { OrganisationAnalytics } from '@api/types';
|
import type { OrganisationAnalytics } from '@api/types';
|
||||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||||
|
|
||||||
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
async function loadOrganisationAnalyticsFromDatabase(orgId: string): Promise<OrganisationAnalytics> {
|
||||||
|
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
|
||||||
|
getDashOrgStats(orgId),
|
||||||
|
getCourseStats(orgId),
|
||||||
|
getRecentCertifications(orgId),
|
||||||
|
getTotalCertificatesIssued(orgId)
|
||||||
|
]);
|
||||||
|
|
||||||
const analytics: OrganisationAnalytics = {
|
const analytics: OrganisationAnalytics = {
|
||||||
totalCertificates: 0,
|
totalCertificates: certificateCountRows[0]?.count ?? 0,
|
||||||
numberOfCourses: 0,
|
numberOfCourses: stats?.[0]?.noOfCourses ?? 0,
|
||||||
totalStudents: 0,
|
totalStudents: stats?.[0]?.enrolledStudents ?? 0,
|
||||||
topCourses: [],
|
topCourses: topCourses.map((course) => ({
|
||||||
recentCertifications: []
|
id: course.courseId,
|
||||||
|
title: course.courseTitle,
|
||||||
|
enrollments: course.totalStudents,
|
||||||
|
completion: course.completionPercentage,
|
||||||
|
certification: course.certificationPercentage
|
||||||
|
})),
|
||||||
|
recentCertifications: recentCertificationRows.map((row) => ({
|
||||||
|
id: row.profileId,
|
||||||
|
avatarUrl: row.avatarUrl,
|
||||||
|
name: row.fullname,
|
||||||
|
courseId: row.courseId,
|
||||||
|
course: row.courseTitle,
|
||||||
|
date: row.earnedAt ?? ''
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return analytics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganisationAnalytics(
|
||||||
|
orgId?: string,
|
||||||
|
siteName?: string,
|
||||||
|
bustCache = false
|
||||||
|
): Promise<OrganisationAnalytics> {
|
||||||
let resolvedOrgId = orgId;
|
let resolvedOrgId = orgId;
|
||||||
|
|
||||||
if (!resolvedOrgId && siteName) {
|
if (!resolvedOrgId && siteName) {
|
||||||
@@ -35,37 +68,26 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resolvedOrgId) {
|
||||||
|
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
|
const { version, data: cached } = await readOrgStatsVersionAndCache<OrganisationAnalytics>(resolvedOrgId);
|
||||||
getDashOrgStats(resolvedOrgId!),
|
|
||||||
getCourseStats(resolvedOrgId!),
|
|
||||||
getRecentCertifications(resolvedOrgId!),
|
|
||||||
getTotalCertificatesIssued(resolvedOrgId!)
|
|
||||||
]);
|
|
||||||
|
|
||||||
analytics.totalCertificates = certificateCountRows[0]?.count ?? 0;
|
if (!bustCache && cached) {
|
||||||
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
|
return cached;
|
||||||
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
|
}
|
||||||
|
|
||||||
analytics.topCourses = topCourses.map((c) => ({
|
const analytics = await loadOrganisationAnalyticsFromDatabase(resolvedOrgId);
|
||||||
id: c.courseId,
|
await writeOrgStatsCache(resolvedOrgId, version, analytics);
|
||||||
title: c.courseTitle,
|
|
||||||
enrollments: c.totalStudents,
|
|
||||||
completion: c.completionPercentage,
|
|
||||||
certification: c.certificationPercentage
|
|
||||||
}));
|
|
||||||
|
|
||||||
analytics.recentCertifications = recentCertificationRows.map((row) => ({
|
|
||||||
id: row.profileId,
|
|
||||||
avatarUrl: row.avatarUrl,
|
|
||||||
name: row.fullname,
|
|
||||||
courseId: row.courseId,
|
|
||||||
course: row.courseTitle,
|
|
||||||
date: row.earnedAt ?? ''
|
|
||||||
}));
|
|
||||||
|
|
||||||
return analytics;
|
return analytics;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Failed to load organisation analytics:', error);
|
console.error('Failed to load organisation analytics:', error);
|
||||||
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
||||||
}
|
}
|
||||||
@@ -168,3 +190,5 @@ export async function getCurrentUserLoginStreak(userId: string) {
|
|||||||
throw new AppError('Failed to load login streak', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
throw new AppError('Failed to load login streak', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { invalidateOrgStats };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
TImportAudienceMembers
|
TImportAudienceMembers
|
||||||
} from '@cio/utils/validation/organization';
|
} from '@cio/utils/validation/organization';
|
||||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||||
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort';
|
import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort';
|
||||||
@@ -194,6 +195,8 @@ async function enrollAudienceStudentProfilesInCourses(
|
|||||||
email: profileEmailMap.get(p.profileId) || undefined
|
email: profileEmailMap.get(p.profileId) || undefined
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await invalidateOrgStats(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validProfiles.length > 0) {
|
if (validProfiles.length > 0) {
|
||||||
@@ -551,6 +554,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
|||||||
if (profiles.length > 0) {
|
if (profiles.length > 0) {
|
||||||
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
|
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
|
||||||
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
|
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
|
||||||
|
await invalidateOrgStats(orgId);
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles(
|
await ensureComplianceEnrollmentRecordsForProfiles(
|
||||||
courseIds,
|
courseIds,
|
||||||
profiles.map((profile) => profile.id)
|
profiles.map((profile) => profile.id)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||||
|
import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache';
|
||||||
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
|
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
|
||||||
|
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
@@ -418,11 +419,16 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
|||||||
// Same as importAudienceMembers after course ids are known: resolve groups from course rows only.
|
// Same as importAudienceMembers after course ids are known: resolve groups from course rows only.
|
||||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||||
await enrollUsersInCourseGroups(
|
const enrolledCount = await enrollUsersInCourseGroups(
|
||||||
validGroupIds,
|
validGroupIds,
|
||||||
[{ profileId: user.id, email: normalizedEmail }],
|
[{ profileId: user.id, email: normalizedEmail }],
|
||||||
invite.invite.roleId
|
invite.invite.roleId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (enrolledCount > 0 && invite.invite.roleId === ROLE.STUDENT) {
|
||||||
|
await invalidateOrgStats(invite.invite.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('acceptOrganizationInvite course enrollment error:', error);
|
console.error('acceptOrganizationInvite course enrollment error:', error);
|
||||||
@@ -710,11 +716,16 @@ export async function acceptOrganizationInviteById(
|
|||||||
try {
|
try {
|
||||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||||
await enrollUsersInCourseGroups(
|
const enrolledCount = await enrollUsersInCourseGroups(
|
||||||
validGroupIds,
|
validGroupIds,
|
||||||
[{ profileId: user.id, email: normalizedEmail }],
|
[{ profileId: user.id, email: normalizedEmail }],
|
||||||
result.invite.roleId
|
result.invite.roleId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (enrolledCount > 0 && result.invite.roleId === ROLE.STUDENT) {
|
||||||
|
await invalidateOrgStats(result.organization.id);
|
||||||
|
}
|
||||||
|
|
||||||
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('acceptOrganizationInviteById course enrollment error:', error);
|
console.error('acceptOrganizationInviteById course enrollment error:', error);
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '
|
|||||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||||
|
|
||||||
// TODO - Replace with actual cache
|
|
||||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
|
||||||
|
|
||||||
function loginActivityDataFromSettled(
|
function loginActivityDataFromSettled(
|
||||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||||
): LoginActivityData {
|
): LoginActivityData {
|
||||||
@@ -26,18 +23,6 @@ export const load = async ({ params, parent, cookies }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgId in cache) {
|
|
||||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
|
||||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
orgName: siteName,
|
|
||||||
stats: cache[orgId],
|
|
||||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||||
safeServerApi<DashStatsSuccess>(() =>
|
safeServerApi<DashStatsSuccess>(() =>
|
||||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||||
@@ -47,11 +32,11 @@ export const load = async ({ params, parent, cookies }) => {
|
|||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
const stats = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orgName: siteName,
|
orgName: siteName,
|
||||||
stats: cache[orgId],
|
stats,
|
||||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
label: 'Organization & team',
|
label: 'Organization & team',
|
||||||
items: [
|
items: [
|
||||||
|
'/guides/admin-dashboard',
|
||||||
'/guides/customize-organization',
|
'/guides/customize-organization',
|
||||||
'/guides/invite-team-member',
|
'/guides/invite-team-member',
|
||||||
'/guides/roles-and-permissions'
|
'/guides/roles-and-permissions'
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ After enrolling, students open the course from **Home** or **My Learning** in th
|
|||||||
|
|
||||||
## Related guides
|
## Related guides
|
||||||
|
|
||||||
|
- [How the Admin Dashboard Works](/guides/admin-dashboard)
|
||||||
- [Creating Your First Course](/guides/create-first-course)
|
- [Creating Your First Course](/guides/create-first-course)
|
||||||
- [Course Types](/guides/course-types)
|
- [Course Types](/guides/course-types)
|
||||||
- [How Course Enrollment Works](/guides/course-enrollment)
|
- [How Course Enrollment Works](/guides/course-enrollment)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: How the Admin Dashboard Works
|
||||||
|
description: What each number on your organization dashboard means, how it is calculated, and how quickly it updates.
|
||||||
|
---
|
||||||
|
|
||||||
|
The **admin dashboard** is the first page you see after signing in as an admin or tutor. It is a snapshot of your organization: how many courses you have, how many students are enrolled, who recently earned a certificate, and which courses are furthest along.
|
||||||
|
|
||||||
|
Open it from **Dashboard** in the left sidebar, or go to `/org/<your-org>/dash`.
|
||||||
|
|
||||||
|
This page is an overview. For a single course’s students, grades, and progress, open that course instead.
|
||||||
|
|
||||||
|
## The three headline numbers
|
||||||
|
|
||||||
|
These cards sit at the top of the dashboard.
|
||||||
|
|
||||||
|
| Card | What it counts | What it does not count |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Certificates issued** | Every certificate a student has earned on an **active** course | Certificates on deleted courses. The same student earning two course certificates counts as two |
|
||||||
|
| **Number of courses** | **Active** courses in this organization | Deleted courses. Draft or unpublished courses still count if they have not been deleted |
|
||||||
|
| **Total students** | Unique students enrolled in at least one **active** course | Tutors and admins. Organization audience members who are not in a course. The same person in several courses still counts as one |
|
||||||
|
|
||||||
|
:::info[Students vs audience]
|
||||||
|
**Total students** is course enrollments, not your full audience list. Someone you imported into Audience but never enrolled in a course will not appear here. See [How Course Enrollment Works](/guides/course-enrollment).
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Top courses
|
||||||
|
|
||||||
|
This list shows up to **five** active courses, ranked by how much lesson progress students have logged.
|
||||||
|
|
||||||
|
Each row includes:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| **Students** | How many students are enrolled in that course |
|
||||||
|
| **Completion** | Share of lessons finished across those students. If a course has 10 lessons and 2 students, there are 20 possible completions. Ten finished lessons show as 50% |
|
||||||
|
| **Certified** | Share of enrolled students who have earned a certificate for that course |
|
||||||
|
|
||||||
|
A few things this bar does **not** include:
|
||||||
|
|
||||||
|
- Exercises and quizzes — those affect course completion and certificates inside the course, but not this dashboard percentage
|
||||||
|
- Tutors taking the course
|
||||||
|
- Deleted courses
|
||||||
|
|
||||||
|
If every course has zero lesson completions, the list can look quiet even when enrollments are high. That is expected.
|
||||||
|
|
||||||
|
## Recent certifications
|
||||||
|
|
||||||
|
The five most recent students who earned a certificate on an active course, with the course name and when they earned it.
|
||||||
|
|
||||||
|
A name appears here only after the student meets that course’s certificate rules — not when they finish a single lesson. Configure those rules in [How to Create Certificates](/guides/certificates).
|
||||||
|
|
||||||
|
## Student login activity
|
||||||
|
|
||||||
|
The bar chart at the bottom answers: **which days of the week are students most active?**
|
||||||
|
|
||||||
|
It looks at the last **90 days**. Each student is counted at most once per day, so a student who logs in five times on Monday still adds one to Monday. The chart is a weekly pattern, not a raw login volume.
|
||||||
|
|
||||||
|
## How fresh the numbers are
|
||||||
|
|
||||||
|
The dashboard is a **snapshot**, not a live ticker. ClassroomIO keeps that snapshot for **10 minutes** so the page stays fast when several people open it.
|
||||||
|
|
||||||
|
Some changes skip the wait and refresh the snapshot right away:
|
||||||
|
|
||||||
|
| What you (or a student) just did | Dashboard updates |
|
||||||
|
| --- | --- |
|
||||||
|
| Create, delete, or change a course’s status | Immediately |
|
||||||
|
| Enroll or unenroll a student | Immediately |
|
||||||
|
| Change someone’s role to or from student | Immediately |
|
||||||
|
| A student earns a certificate | Immediately |
|
||||||
|
| Reset a student’s course progress | Immediately |
|
||||||
|
| A student marks a lesson complete | Within 10 minutes |
|
||||||
|
| Students log in | Login chart: within about a day |
|
||||||
|
|
||||||
|
That is why creating a course should bump **Number of courses** as soon as you return to the dashboard, while a lesson completion may take a few minutes to move the **Completion** bar.
|
||||||
|
|
||||||
|
:::tip[If a number looks wrong]
|
||||||
|
Refresh the page once. If you just created a course or enrolled someone, the headline cards should already match. If you are watching lesson completion, wait up to 10 minutes — one finished lesson often will not move a rounded percentage anyway.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Common questions
|
||||||
|
|
||||||
|
**Why is Total students smaller than my Audience list?**
|
||||||
|
Audience includes people you invited or imported who may not be in a course yet. The dashboard only counts students enrolled in an active course.
|
||||||
|
|
||||||
|
**Why did Number of courses not include a course I just deleted?**
|
||||||
|
Deleted courses are left out on purpose. Only active courses are counted.
|
||||||
|
|
||||||
|
**Why is Completion 0% when students are taking quizzes?**
|
||||||
|
The dashboard completion bar is lesson-based. Exercise progress shows inside the course, not on this overview.
|
||||||
|
|
||||||
|
**Why did a certificate not show up under Certificates issued?**
|
||||||
|
Certificates need to be enabled for your plan, and the student must meet that course’s rules (threshold, optional final exercise, optional deadline). Free-plan organizations do not issue certificates.
|
||||||
|
|
||||||
|
## Related guides
|
||||||
|
|
||||||
|
- [Creating Your First Course](/guides/create-first-course)
|
||||||
|
- [How Course Enrollment Works](/guides/course-enrollment)
|
||||||
|
- [How to Create Certificates](/guides/certificates)
|
||||||
|
- [Control Course Progression](/guides/course-progression)
|
||||||
|
- [Student Dashboard](/student-dashboard) — what learners see after they sign in
|
||||||
@@ -90,6 +90,7 @@ Once a student meets all the configured requirements, their certificate appears
|
|||||||
|
|
||||||
## Related guides
|
## Related guides
|
||||||
|
|
||||||
|
- [How the Admin Dashboard Works](/guides/admin-dashboard) — certificates issued and recent certifications on the org dashboard
|
||||||
- [Creating Your First Course](/guides/create-first-course) — course setup overview
|
- [Creating Your First Course](/guides/create-first-course) — course setup overview
|
||||||
- [How to Create an Exercise](/guides/create-exercise) — create the exercise you reference as a final exercise
|
- [How to Create an Exercise](/guides/create-exercise) — create the exercise you reference as a final exercise
|
||||||
- [How to Grade an Exercise](/guides/grade-exercise) — grade submissions before certificates are issued
|
- [How to Grade an Exercise](/guides/grade-exercise) — grade submissions before certificates are issued
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ classes, enrolling students, publishing your landing pages, and managing your or
|
|||||||
<Card title="Terminology" href="/terminology" icon="book-a">
|
<Card title="Terminology" href="/terminology" icon="book-a">
|
||||||
The words ClassroomIO uses, and what they mean.
|
The words ClassroomIO uses, and what they mean.
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title="Admin dashboard" href="/guides/admin-dashboard" icon="chart-column">
|
||||||
|
What each dashboard number means, and how quickly it updates.
|
||||||
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
## Developer Guides
|
## Developer Guides
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy
|
|||||||
Share your academy URL with students. The generic `app.classroomio.com` host is for platform access when no org subdomain applies. Course and enroll links should use your academy when possible.
|
Share your academy URL with students. The generic `app.classroomio.com` host is for platform access when no org subdomain applies. Course and enroll links should use your academy when possible.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Dashboard numbers
|
||||||
|
|
||||||
|
The organization **Dashboard** is a snapshot of active courses, unique enrolled students, certificates, and login patterns. See [How the Admin Dashboard Works](/guides/admin-dashboard) for what each number includes and how quickly it refreshes.
|
||||||
|
|
||||||
## Courses and cohorts inside an org
|
## Courses and cohorts inside an org
|
||||||
|
|
||||||
- **Courses** are individual learning containers. See [Course](/course).
|
- **Courses** are individual learning containers. See [Course](/course).
|
||||||
@@ -31,6 +35,7 @@ Share your academy URL with students. The generic `app.classroomio.com` host is
|
|||||||
|
|
||||||
## Related guides
|
## Related guides
|
||||||
|
|
||||||
|
- [How the Admin Dashboard Works](/guides/admin-dashboard)
|
||||||
- [Customize Your Organization](/guides/customize-organization)
|
- [Customize Your Organization](/guides/customize-organization)
|
||||||
- [Customize Your Academy Landing Page](/guides/org-landing-page)
|
- [Customize Your Academy Landing Page](/guides/org-landing-page)
|
||||||
- [Set Up a Custom Domain](/guides/custom-domain)
|
- [Set Up a Custom Domain](/guides/custom-domain)
|
||||||
|
|||||||
@@ -82,5 +82,6 @@ If you added them from **People** but they have not signed in yet, they may show
|
|||||||
|
|
||||||
- [How Course Enrollment Works](/guides/course-enrollment) — how students join courses and what settings control access
|
- [How Course Enrollment Works](/guides/course-enrollment) — how students join courses and what settings control access
|
||||||
- [How to Invite Students](/guides/invite-students) — send enrollment links by email
|
- [How to Invite Students](/guides/invite-students) — send enrollment links by email
|
||||||
|
- [How the Admin Dashboard Works](/guides/admin-dashboard) — org-level numbers admins see
|
||||||
- [Cohorts](/cohorts) — multi-course tracks and goals for admins
|
- [Cohorts](/cohorts) — multi-course tracks and goals for admins
|
||||||
- [Create a Course Landing Page](/guides/course-landingpage) — what prospects see before enrolling
|
- [Create a Course Landing Page](/guides/course-landingpage) — what prospects see before enrolling
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getCourseProgress as getCourseProgressQuery,
|
getCourseProgress as getCourseProgressQuery,
|
||||||
getCourseById,
|
getCourseById,
|
||||||
getCourseWithRelations,
|
getCourseWithRelations,
|
||||||
|
getOrgIdByCourseId,
|
||||||
updateCourse as updateCourseQuery,
|
updateCourse as updateCourseQuery,
|
||||||
updateLessonsSectionId
|
updateLessonsSectionId
|
||||||
} from '@cio/db/queries/course';
|
} from '@cio/db/queries/course';
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
import { getStudentLimit } from '@cio/utils/plans';
|
import { getStudentLimit } from '@cio/utils/plans';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
|
import { invalidateOrgStats } from '../../utils/redis/org-stats-cache';
|
||||||
import { annotateCourseContentWithProgression } from './progression';
|
import { annotateCourseContentWithProgression } from './progression';
|
||||||
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
||||||
import { guardCourseTypeTransition } from './public-course-guard';
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +313,8 @@ export async function createCourse(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await invalidateOrgStats(data.organizationId);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
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;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
@@ -440,10 +453,14 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
|||||||
*/
|
*/
|
||||||
export async function deleteCourse(courseId: string) {
|
export async function deleteCourse(courseId: string) {
|
||||||
try {
|
try {
|
||||||
|
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||||
const deleted = await deleteCourseQuery(courseId);
|
const deleted = await deleteCourseQuery(courseId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await invalidateOrgStats(statsOrgId);
|
||||||
|
|
||||||
return deleted;
|
return deleted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
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);
|
||||||
|
}
|
||||||
@@ -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)
|
* Gets course with organization data (title, org name, org siteName, groupId)
|
||||||
* @param courseId Course ID
|
* @param courseId Course ID
|
||||||
|
|||||||
@@ -328,3 +328,23 @@ export async function insertGroupMembersOnConflictDoNothing(
|
|||||||
throw new Error(`Failed to insert group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
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'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import * as z from 'zod';
|
|||||||
export const ZDashStats = z
|
export const ZDashStats = z
|
||||||
.object({
|
.object({
|
||||||
orgId: z.string().optional(),
|
orgId: z.string().optional(),
|
||||||
siteName: z.string().optional()
|
siteName: z.string().optional(),
|
||||||
|
bust: z
|
||||||
|
.enum(['0', '1'])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === '1')
|
||||||
})
|
})
|
||||||
.refine((v) => !!(v.orgId || v.siteName), {
|
.refine((v) => !!(v.orgId || v.siteName), {
|
||||||
message: 'Either orgId or siteName is required'
|
message: 'Either orgId or siteName is required'
|
||||||
|
|||||||
Reference in New Issue
Block a user