feat(billing): enforce free-plan student cap across the app (#733)

* feat(billing): enforce free-plan student cap across the app

Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.

Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.

Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.

Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.

* feat(billing): guard remaining student-creating paths against the cap

The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:

- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)

For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.

* fix(billing): don't show cap block to existing org members on enroll page

The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.

Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.

* feat(billing): deliver org plan-limits via /account with an extensible registry

Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).

Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
  (reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
  OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
  sidebars, upgrade card, People pages, and invite modals read these instead
  of fetching audience rows.

Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.

Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.

* refactor(billing): address PR review on the limits work

- Dedupe new student profile IDs before the cohort seat check so duplicate
  batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
  flag (audience config declares `upgradeResource: 'students'`; groups set it
  from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
  `disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
  convention (operator-controlled reruns) so idempotency isn't reflagged.

* fix(billing): name the org in the enroll capacity message

Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.

* style(billing): use PremiumIcon for the Authentication upgrade indicator

Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
This commit is contained in:
Rotimi Best
2026-07-12 11:33:04 +01:00
committed by GitHub
parent 0b5247bed3
commit fd8bec3e70
50 changed files with 740 additions and 109 deletions
+19
View File
@@ -1,10 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countActiveStudents,
createOrganizationMember,
getFirstOrganization,
getOrganizationByProfileId,
hasOrgMemberByProfileIdOrEmail
} from '@cio/db/queries/organization';
import { getPlanLimit, toResourceUsage } from '@cio/utils/plans';
import {
getProfileByEmail,
getProfileById,
@@ -65,6 +67,23 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
}
}
// Attach per-resource usage + plan limits for admin/tutor members only.
// Plain COUNTs (no rows) so the payload stays light and students never
// receive org limit data. Skip self-hosted (unlimited, matching the guard).
if (!isSelfHosted) {
await Promise.all(
organizations.map(async (org) => {
if (org.roleId !== ROLE.ADMIN && org.roleId !== ROLE.TUTOR) return;
const activePlan = org.plans.find((plan) => plan.isActive);
const studentsUsed = await countActiveStudents(org.id);
const studentsLimit = getPlanLimit('students', activePlan?.planName);
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
})
);
}
return {
profile,
organizations,
+26 -1
View File
@@ -42,9 +42,14 @@ import {
import { getCourseGroupIds } from '@cio/db/queries/course';
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
import { getProfileByEmail } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import {
getOrgMembersByProfileIds,
getOrganizationMemberIdByOrgAndProfile,
insertOrganizationMembersOnConflictDoNothing
} from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { assertStudentCapacityOrThrow } from '../organization/student-limit';
type CohortMemberEnrollment = {
profileId: string;
@@ -64,6 +69,17 @@ async function enrollCohortStudentsInGroups(
return 0;
}
const studentProfileIds = studentMembers
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId));
const existingMembers = await getOrgMembersByProfileIds(organizationId, studentProfileIds);
const existingMemberProfileIds = new Set(existingMembers.map((member) => member.profileId));
const newStudentProfileIds = new Set(
studentProfileIds.filter((profileId) => !existingMemberProfileIds.has(profileId))
);
await assertStudentCapacityOrThrow(organizationId, newStudentProfileIds.size);
const organizationMemberRows = studentMembers.map((member) => ({
organizationId,
roleId: ROLE.STUDENT,
@@ -241,6 +257,15 @@ export async function addCohortMembers(cohortId: string, data: TAddCohortMembers
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
const existingOrgMemberId = await getOrganizationMemberIdByOrgAndProfile(
cohort.organizationId,
member.profileId,
tx
);
if (!existingOrgMemberId) {
await assertStudentCapacityOrThrow(cohort.organizationId, 1);
}
await insertOrganizationMembersOnConflictDoNothing(
[
{
+5
View File
@@ -34,6 +34,7 @@ import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { assertStudentCapacityOrThrow } from '../organization/student-limit';
import { getWelcomeSessionIcs } from './session-invite';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
@@ -671,6 +672,8 @@ export async function enrollInCourse(
}
if (!orgMemberId) {
await assertStudentCapacityOrThrow(org.id, 1);
await createOrganizationMember({
organizationId: org.id,
roleId: ROLE.STUDENT,
@@ -1001,6 +1004,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(organization.id, user.id, tx);
if (!orgMemberId) {
await assertStudentCapacityOrThrow(organization.id, 1);
await createOrganizationMember(
{
organizationId: organization.id,
@@ -28,6 +28,7 @@ import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { assertStudentCapacityOrThrow } from './student-limit';
import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
import { getWelcomeSessionIcs } from '../course/session-invite';
@@ -528,6 +529,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
let importEmailsFailed = 0;
if (newEmails.length > 0) {
await assertStudentCapacityOrThrow(orgId, newEmails.length);
await createOrganizationMembers(
newEmails.map((email) => ({
organizationId: orgId,
@@ -7,6 +7,7 @@ import {
import { ROLE } from '@cio/utils/constants';
import { getProfileById } from '@cio/db/queries/auth';
import { assertStudentCapacityOrThrow } from './student-limit';
interface OrgSignupSettings {
signup?: {
@@ -56,6 +57,8 @@ export async function autoEnrollStudent(userId: string, orgId: string): Promise<
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
}
await assertStudentCapacityOrThrow(orgId, 1);
await createOrganizationMember({
organizationId: orgId,
profileId: userId,
@@ -28,6 +28,7 @@ import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { assertStudentCapacityOrThrow } from './student-limit';
import { getAppBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
@@ -137,6 +138,10 @@ async function syncOrgMemberForOrgInvite(
return;
}
if (params.roleId === ROLE.STUDENT) {
await assertStudentCapacityOrThrow(params.organizationId, 1);
}
await createOrganizationMember(
{
organizationId: params.organizationId,
@@ -0,0 +1,72 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { buildEmailBranding } from '@cio/email';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getStudentLimit } from '@cio/utils/plans';
import { env } from '@cio/core/config/env';
import {
countActiveStudents,
getActiveOrganizationPlan,
getOrganizationAdminEmails,
getOrganizationById
} from '@cio/db/queries/organization';
/**
* Notifies org admins that the org has hit its plan's student limit. Enqueued
* with a per-day idempotency key so repeated blocked attempts don't spam admins.
*/
async function notifyAdminsStudentLimitReached(
orgId: string,
studentCount: number,
studentLimit: number
): Promise<void> {
const [admins, org] = await Promise.all([getOrganizationAdminEmails(orgId), getOrganizationById(orgId)]);
if (!admins.length || !org) return;
const todayKey = new Date().toISOString().slice(0, 10);
const upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`;
await enqueueTransactionalEmail('studentLimitReached', {
to: admins.map((admin) => admin.email),
fields: {
orgName: org.name,
studentCount,
studentLimit,
upgradeUrl,
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme })
},
idempotencyKey: `student-limit-reached:${orgId}:${todayKey}`
});
}
/**
* Throws `UPGRADE_REQUIRED` when adding `additionalStudents` new org members
* with the student role would push the org past its plan's student limit.
* Self-hosted orgs and paid plans with an unlimited allowance are exempt.
*
* Fires an admin-notification email (fire-and-forget) the moment the limit is hit.
*/
export async function assertStudentCapacityOrThrow(orgId: string, additionalStudents: number): Promise<void> {
if (additionalStudents <= 0) return;
if (env.PUBLIC_IS_SELFHOSTED === 'true') return;
const activePlan = await getActiveOrganizationPlan(orgId);
const limit = getStudentLimit(activePlan?.planName);
if (!Number.isFinite(limit)) return;
const currentCount = await countActiveStudents(orgId);
if (currentCount + additionalStudents > limit) {
notifyAdminsStudentLimitReached(orgId, currentCount, limit).catch((error) => {
console.error('notifyAdminsStudentLimitReached error:', error);
});
throw new AppError(
`This organization has reached its ${limit}-student limit on the Free plan`,
ErrorCodes.UPGRADE_REQUIRED,
403
);
}
}