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
@@ -21,8 +21,9 @@
import { shortenName } from '$lib/utils/functions/string';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isOrgAdmin, isStudentLimitReached } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { UpgradeBanner } from '$features/ui';
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
import { cohortApi } from '$features/cohort/api';
@@ -148,6 +149,10 @@
<Page.Body>
{#snippet child()}
{#if $isStudentLimitReached}
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
{/if}
<GoalsOverviewTiles cohortId={data.cohortId} />
<section class="space-y-2">
@@ -18,6 +18,7 @@
}
const audienceLength = $derived(data.pagination?.total || 0);
const atStudentLimit = $derived(audienceLength >= $currentOrgMaxAudience);
</script>
<svelte:head>
@@ -41,7 +42,12 @@
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
{$t('audience.export')}
</Button>
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
<Button
variant="secondary"
disabled={atStudentLimit}
href={atStudentLimit ? '#' : resolve(`${page.url.pathname}/import`, {})}
title={atStudentLimit ? $t('audience.import_limit_reached') : undefined}
>
{$t('audience.import_users')}
</Button>
</Page.Action>
@@ -51,6 +51,7 @@ export const load = async ({ params, url }) => {
inviteEmail: data.inviteContext?.recipientEmail ?? null,
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
requiresPaymentOrInvite: false,
studentLimitReached: false,
token: inviteToken
};
}
@@ -97,6 +98,7 @@ export const load = async ({ params, url }) => {
currentOrg,
invite: null,
requiresPaymentOrInvite: !isFree,
studentLimitReached: courseData.studentLimitReached ?? false,
token: null as string | null
};
};
@@ -54,8 +54,14 @@
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE');
// The org-wide cap only applies to *new* students joining the org. We can't
// know the viewer's membership here (public, unauthenticated load), so 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) for new ones.
const blocksNewSignup = $derived(Boolean(data.studentLimitReached) && !isLoggedIn);
const canJoinCourse = $derived(
data.requiresPaymentOrInvite
data.requiresPaymentOrInvite || blocksNewSignup
? false
: (hasActiveInvite || (!data.invite && data.course?.allowNewStudent !== false)) &&
data.course?.status === 'ACTIVE' &&
@@ -63,6 +69,11 @@
);
function getBlockedMessage(): string {
if (blocksNewSignup) {
return t.get('course.navItem.landing_page.enroll_page.student_limit_reached', {
orgName: data.currentOrg?.name ?? ''
});
}
if (data.requiresPaymentOrInvite) {
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
}