diff --git a/apps/api/src/__tests__/dash-org-stats-cache.test.ts b/apps/api/src/__tests__/dash-org-stats-cache.test.ts new file mode 100644 index 00000000..3e275522 --- /dev/null +++ b/apps/api/src/__tests__/dash-org-stats-cache.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/routes/dash/stats.ts b/apps/api/src/routes/dash/stats.ts index 0bdfc1ac..72409886 100644 --- a/apps/api/src/routes/dash/stats.ts +++ b/apps/api/src/routes/dash/stats.ts @@ -41,9 +41,9 @@ export const dashAnalyticsRouter = new Hono() }) .get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => { 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); } catch (error) { diff --git a/apps/api/src/services/course/clone.ts b/apps/api/src/services/course/clone.ts index 32d191a4..ad5ed89e 100644 --- a/apps/api/src/services/course/clone.ts +++ b/apps/api/src/services/course/clone.ts @@ -23,6 +23,7 @@ import { } from '@db/queries'; import { ROLE } from '@cio/utils/constants'; +import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache'; import { QUESTION_TYPE_IDS } from '@cio/question-types'; async function cloneLessonLanguages(newLessons: TLesson[], oldLessons: TLesson[]): Promise { @@ -237,6 +238,9 @@ export async function cloneCourse( type: course.type }); + const statsOrgId = organizationId ?? newGroup.organizationId; + await invalidateOrgStats(statsOrgId); + // 4. add group member await addGroupMember({ profileId: userId, diff --git a/apps/api/src/services/course/completion.ts b/apps/api/src/services/course/completion.ts index 05a8f398..93625e3f 100644 --- a/apps/api/src/services/course/completion.ts +++ b/apps/api/src/services/course/completion.ts @@ -19,6 +19,7 @@ import { claimMemberCertificateEarned } from '@cio/db/queries/course/people'; import { getActiveOrganizationPlan } from '@cio/db/queries/organization'; import { PLAN } from '@cio/utils/plans'; import { env } from '@cio/core/config/env'; +import { invalidateOrgStats } from '@cio/core/utils/redis/org-stats-cache'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; export type CertificationBlocker = { @@ -238,6 +239,8 @@ export async function evaluateCourseCertification( if (didClaimCertificate) { isNewCompletion = true; + await invalidateOrgStats(courseRow.orgId); + scheduleCertificationCompletionWork({ courseId, profileId, diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index b150bf3d..07b0bbfc 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -27,6 +27,7 @@ import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite'; import type { TNewCourseInviteAudit } from '@db/types'; import crypto from 'node:crypto'; 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 { getProfileById } from '@cio/db/queries/auth'; import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email'; @@ -693,6 +694,8 @@ export async function enrollInCourse( email: normalizedEmail }); + await invalidateOrgStats(org.id); + await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]); 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]); if (!result.alreadyJoined) { diff --git a/apps/api/src/services/course/people.ts b/apps/api/src/services/course/people.ts index 01e88b5e..019b4a37 100644 --- a/apps/api/src/services/course/people.ts +++ b/apps/api/src/services/course/people.ts @@ -13,7 +13,8 @@ import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progres import type { TAddCourseMembers } from '@cio/utils/validation/course/people'; import type { TGroupmember } from '@cio/db/types'; 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 { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; @@ -57,6 +58,11 @@ export async function addMember( 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) { await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]); } @@ -201,8 +207,25 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { theme: courseOrgData.orgTheme }); - // Add all members - const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member))); + // Add members sequentially so partial failures still invalidate stats for successful inserts. + 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 .filter((member) => member.roleId === ROLE.STUDENT && 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) { 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); if (!updated) { 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; } catch (error) { if (error instanceof AppError) { @@ -308,6 +347,12 @@ export async function deleteMember(courseId: string, memberId: string) { if (!deleted) { 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; } catch (error) { if (error instanceof AppError) { @@ -348,6 +393,9 @@ export async function resetMemberCourseProgress(courseId: string, memberId: stri summary }); + const statsOrgId = await getOrgIdByCourseId(courseId); + await invalidateOrgStats(statsOrgId); + return summary; } catch (error) { if (error instanceof AppError) { diff --git a/apps/api/src/services/dash.ts b/apps/api/src/services/dash.ts index 44e6427a..ba1d9528 100644 --- a/apps/api/src/services/dash.ts +++ b/apps/api/src/services/dash.ts @@ -10,19 +10,52 @@ import { } from '@cio/db/queries/dash'; import { dashLoginActivityKey } from '@api/utils/redis/key-generators'; 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'; -export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise { +async function loadOrganisationAnalyticsFromDatabase(orgId: string): Promise { + const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([ + getDashOrgStats(orgId), + getCourseStats(orgId), + getRecentCertifications(orgId), + getTotalCertificatesIssued(orgId) + ]); + const analytics: OrganisationAnalytics = { - totalCertificates: 0, - numberOfCourses: 0, - totalStudents: 0, - topCourses: [], - recentCertifications: [] + totalCertificates: certificateCountRows[0]?.count ?? 0, + numberOfCourses: stats?.[0]?.noOfCourses ?? 0, + totalStudents: stats?.[0]?.enrolledStudents ?? 0, + topCourses: topCourses.map((course) => ({ + 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 { let resolvedOrgId = orgId; 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 { - const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([ - getDashOrgStats(resolvedOrgId!), - getCourseStats(resolvedOrgId!), - getRecentCertifications(resolvedOrgId!), - getTotalCertificatesIssued(resolvedOrgId!) - ]); + const { version, data: cached } = await readOrgStatsVersionAndCache(resolvedOrgId); - analytics.totalCertificates = certificateCountRows[0]?.count ?? 0; - analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0; - analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0; + if (!bustCache && cached) { + return cached; + } - analytics.topCourses = topCourses.map((c) => ({ - id: c.courseId, - 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 ?? '' - })); + const analytics = await loadOrganisationAnalyticsFromDatabase(resolvedOrgId); + await writeOrgStatsCache(resolvedOrgId, version, analytics); return analytics; } catch (error) { + if (error instanceof AppError) { + throw error; + } + console.error('Failed to load organisation analytics:', error); 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); } } + +export { invalidateOrgStats }; diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts index 6df6a609..664392ac 100644 --- a/apps/api/src/services/organization/audience.ts +++ b/apps/api/src/services/organization/audience.ts @@ -5,6 +5,7 @@ import type { TImportAudienceMembers } from '@cio/utils/validation/organization'; 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 { enqueueTransactionalEmail } from '@api/services/jobs'; import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort'; @@ -194,6 +195,8 @@ async function enrollAudienceStudentProfilesInCourses( email: profileEmailMap.get(p.profileId) || undefined })) ); + + await invalidateOrgStats(orgId); } if (validProfiles.length > 0) { @@ -551,6 +554,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience if (profiles.length > 0) { const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined })); await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT); + await invalidateOrgStats(orgId); await ensureComplianceEnrollmentRecordsForProfiles( courseIds, profiles.map((profile) => profile.id) diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 00db1f9e..47b7c5f7 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -22,6 +22,7 @@ import { } from '@cio/db/queries/organization'; import { getCourseGroupIds } from '@cio/db/queries/course'; 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 { 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. const courseGroupMappings = await getCourseGroupIds(courseIds); const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; - await enrollUsersInCourseGroups( + const enrolledCount = await enrollUsersInCourseGroups( validGroupIds, [{ profileId: user.id, email: normalizedEmail }], invite.invite.roleId ); + + if (enrolledCount > 0 && invite.invite.roleId === ROLE.STUDENT) { + await invalidateOrgStats(invite.invite.organizationId); + } + await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]); } catch (error) { console.error('acceptOrganizationInvite course enrollment error:', error); @@ -710,11 +716,16 @@ export async function acceptOrganizationInviteById( try { const courseGroupMappings = await getCourseGroupIds(courseIds); const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[]; - await enrollUsersInCourseGroups( + const enrolledCount = await enrollUsersInCourseGroups( validGroupIds, [{ profileId: user.id, email: normalizedEmail }], result.invite.roleId ); + + if (enrolledCount > 0 && result.invite.roleId === ROLE.STUDENT) { + await invalidateOrgStats(result.organization.id); + } + await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]); } catch (error) { console.error('acceptOrganizationInviteById course enrollment error:', error); diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/dash/+page.server.ts b/apps/dashboard/src/routes/(app)/org/[slug]/dash/+page.server.ts index 1554352b..9f26e414 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/dash/+page.server.ts +++ b/apps/dashboard/src/routes/(app)/org/[slug]/dash/+page.server.ts @@ -2,9 +2,6 @@ import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from ' import { classroomio, getApiHeaders } from '$lib/utils/services/api'; import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server'; -// TODO - Replace with actual cache -const cache: Record = {}; - function loginActivityDataFromSettled( result: PromiseSettledResult> ): LoginActivityData { @@ -26,18 +23,6 @@ export const load = async ({ params, parent, cookies }) => { }; } - if (orgId in cache) { - const loginActivityResult = await safeServerApi(() => - 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([ safeServerApi(() => 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 { orgName: siteName, - stats: cache[orgId], + stats, loginActivity: loginActivityDataFromSettled(loginActivityResult) }; }; diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts index bcb0b5e2..74815a76 100644 --- a/apps/docs/blume.config.ts +++ b/apps/docs/blume.config.ts @@ -140,6 +140,7 @@ export default defineConfig({ { label: 'Organization & team', items: [ + '/guides/admin-dashboard', '/guides/customize-organization', '/guides/invite-team-member', '/guides/roles-and-permissions' diff --git a/apps/docs/content/docs/course.mdx b/apps/docs/content/docs/course.mdx index 620122b0..9c89ffc9 100644 --- a/apps/docs/content/docs/course.mdx +++ b/apps/docs/content/docs/course.mdx @@ -53,6 +53,7 @@ After enrolling, students open the course from **Home** or **My Learning** in th ## Related guides +- [How the Admin Dashboard Works](/guides/admin-dashboard) - [Creating Your First Course](/guides/create-first-course) - [Course Types](/guides/course-types) - [How Course Enrollment Works](/guides/course-enrollment) diff --git a/apps/docs/content/docs/guides/admin-dashboard.mdx b/apps/docs/content/docs/guides/admin-dashboard.mdx new file mode 100644 index 00000000..e89d1571 --- /dev/null +++ b/apps/docs/content/docs/guides/admin-dashboard.mdx @@ -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//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 diff --git a/apps/docs/content/docs/guides/certificates.mdx b/apps/docs/content/docs/guides/certificates.mdx index 5ecc83a1..7dd6b8c9 100644 --- a/apps/docs/content/docs/guides/certificates.mdx +++ b/apps/docs/content/docs/guides/certificates.mdx @@ -90,6 +90,7 @@ Once a student meets all the configured requirements, their certificate appears ## 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 - [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 diff --git a/apps/docs/content/docs/guides/index.mdx b/apps/docs/content/docs/guides/index.mdx index 9b66649d..56226871 100644 --- a/apps/docs/content/docs/guides/index.mdx +++ b/apps/docs/content/docs/guides/index.mdx @@ -23,6 +23,9 @@ classes, enrolling students, publishing your landing pages, and managing your or The words ClassroomIO uses, and what they mean. + + What each dashboard number means, and how quickly it updates. + ## Developer Guides diff --git a/apps/docs/content/docs/organization.mdx b/apps/docs/content/docs/organization.mdx index 05f2755e..586d3205 100644 --- a/apps/docs/content/docs/organization.mdx +++ b/apps/docs/content/docs/organization.mdx @@ -23,6 +23,10 @@ On creation, each org gets `https://.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. ::: +## 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** 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 +- [How the Admin Dashboard Works](/guides/admin-dashboard) - [Customize Your Organization](/guides/customize-organization) - [Customize Your Academy Landing Page](/guides/org-landing-page) - [Set Up a Custom Domain](/guides/custom-domain) diff --git a/apps/docs/content/docs/student-dashboard.mdx b/apps/docs/content/docs/student-dashboard.mdx index a8bf9dd2..7ca89b2c 100644 --- a/apps/docs/content/docs/student-dashboard.mdx +++ b/apps/docs/content/docs/student-dashboard.mdx @@ -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 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 - [Create a Course Landing Page](/guides/course-landingpage) — what prospects see before enrolling diff --git a/packages/core/src/services/course/course.ts b/packages/core/src/services/course/course.ts index e257e0c2..d925be32 100644 --- a/packages/core/src/services/course/course.ts +++ b/packages/core/src/services/course/course.ts @@ -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) { } } + 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) { */ 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) { diff --git a/packages/core/src/utils/redis/org-stats-cache.ts b/packages/core/src/utils/redis/org-stats-cache.ts new file mode 100644 index 00000000..36464735 --- /dev/null +++ b/packages/core/src/utils/redis/org-stats-cache.ts @@ -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 = { + version: string; + data: T; +}; + +function parseCachedOrgStatsPayload(raw: string): CachedOrgStatsPayload | 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; +} + +export async function readOrgStatsVersionAndCache(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(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(orgId: string, version: string, value: T): Promise { + if (!env.REDIS_URL) { + return; + } + + try { + const payload: CachedOrgStatsPayload = { 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 { + 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 { + const uniqueOrgIds = [...new Set(orgIds.filter((orgId) => Boolean(orgId)))]; + + for (const orgId of uniqueOrgIds) { + await invalidateOrgStats(orgId); + } +} + +export async function invalidateOrgStatsForGroupIds(groupIds: string[]): Promise { + const orgIds = await getOrgIdsByGroupIds(groupIds); + await invalidateOrgStatsForIds(orgIds); +} diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index 9e970310..dbf9a9d9 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -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 { + 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 diff --git a/packages/db/src/queries/group/group.ts b/packages/db/src/queries/group/group.ts index ebf5abce..aae3fa65 100644 --- a/packages/db/src/queries/group/group.ts +++ b/packages/db/src/queries/group/group.ts @@ -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 { + 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'}` + ); + } +} diff --git a/packages/utils/src/validation/dash/stats.ts b/packages/utils/src/validation/dash/stats.ts index cb0debf4..4f7f6d8a 100644 --- a/packages/utils/src/validation/dash/stats.ts +++ b/packages/utils/src/validation/dash/stats.ts @@ -3,7 +3,11 @@ import * as z from 'zod'; export const ZDashStats = z .object({ 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), { message: 'Either orgId or siteName is required'