* 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.
233 lines
6.8 KiB
Svelte
233 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import * as Dialog from '@cio/ui/base/dialog';
|
|
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
|
import { Button } from '@cio/ui/base/button';
|
|
import { ROLE } from '@cio/utils/constants';
|
|
import { t } from '$lib/utils/functions/translations';
|
|
import { currentOrg, isStudentLimitReached } from '$lib/utils/store/org';
|
|
import { orgApi } from '$features/org/api/org.svelte';
|
|
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
|
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
|
import { UpgradeBanner } from '$features/ui';
|
|
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
|
import type { OrgTeamMember } from '$lib/utils/types/org';
|
|
import { cohortApi } from '../api';
|
|
|
|
interface Props {
|
|
cohortId: string;
|
|
}
|
|
|
|
let { cohortId }: Props = $props();
|
|
|
|
let tutors = $state<Tutor[]>([]);
|
|
let selectedIds = $state<string[]>([]);
|
|
let isLoadingStudents = $state(false);
|
|
let activeTab = $state<'tutors' | 'students'>('students');
|
|
|
|
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
|
|
const isOpen = $derived(addPeopleParam === 'true');
|
|
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
|
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, cohortApi.members));
|
|
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
|
|
|
function getTutors(team: OrgTeamMember[]) {
|
|
const existingTutorIds = new Set(
|
|
cohortApi.members
|
|
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
|
|
.map((member) => member.profileId)
|
|
.filter((profileId): profileId is string => Boolean(profileId))
|
|
);
|
|
|
|
return team
|
|
.filter((teamMember) => teamMember.verified)
|
|
.filter((teamMember) => !existingTutorIds.has(teamMember.profileId))
|
|
.map((teamMember) => ({
|
|
id: teamMember.id,
|
|
text: teamMember.fullname,
|
|
profileId: teamMember.profileId,
|
|
email: teamMember.email
|
|
}));
|
|
}
|
|
|
|
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
|
|
const existingStudentIds = new Set(
|
|
cohortMembers
|
|
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
|
.map((member) => member.profileId)
|
|
.filter((profileId): profileId is string => Boolean(profileId))
|
|
);
|
|
|
|
return students.filter((student) => {
|
|
if (!student.profileId) {
|
|
return false;
|
|
}
|
|
|
|
return !existingStudentIds.has(student.profileId);
|
|
});
|
|
}
|
|
|
|
function closeModal() {
|
|
goto(resolve(page.url.pathname, {}));
|
|
}
|
|
|
|
function setTutors(orgId: string | undefined) {
|
|
if (!orgId) {
|
|
return;
|
|
}
|
|
|
|
untrack(async () => {
|
|
await orgApi.getOrgTeam();
|
|
if (orgApi.error) {
|
|
console.error('Error fetching teams', orgApi.error);
|
|
return;
|
|
}
|
|
|
|
tutors = getTutors(orgApi.teamMembers);
|
|
});
|
|
}
|
|
|
|
function loadStudents(orgId: string | undefined) {
|
|
if (!orgId) {
|
|
return;
|
|
}
|
|
|
|
untrack(async () => {
|
|
isLoadingStudents = true;
|
|
|
|
try {
|
|
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
|
} finally {
|
|
isLoadingStudents = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleStudentSearch(value: string) {
|
|
const orgId = $currentOrg.id;
|
|
if (!orgId) {
|
|
return;
|
|
}
|
|
|
|
isLoadingStudents = true;
|
|
|
|
try {
|
|
await orgApi.getOrgAudience(
|
|
orgId,
|
|
{
|
|
...DEFAULT_ORG_AUDIENCE_QUERY,
|
|
search: value.trim() || undefined
|
|
},
|
|
{ abortPrevious: true }
|
|
);
|
|
} finally {
|
|
isLoadingStudents = false;
|
|
}
|
|
}
|
|
|
|
async function addTutors() {
|
|
if (!selectedTutors.length) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
|
|
cohortApi.success = false;
|
|
|
|
await cohortApi.addMembers(cohortId, {
|
|
members: selectedTutors.map((tutor) => ({
|
|
profileId: tutor.profileId,
|
|
roleId: ROLE.TUTOR,
|
|
email: tutor.email,
|
|
name: tutor.text
|
|
}))
|
|
});
|
|
|
|
if (cohortApi.success) {
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
|
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
|
|
}
|
|
|
|
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
|
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
|
|
}
|
|
|
|
$effect(() => {
|
|
setTutors($currentOrg.id);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!isOpen || !cohortId) {
|
|
return;
|
|
}
|
|
|
|
untrack(() => {
|
|
activeTab = 'students';
|
|
selectedIds = [];
|
|
void loadStudents($currentOrg.id);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) closeModal();
|
|
}}
|
|
>
|
|
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
|
<Dialog.Header>
|
|
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
|
|
</Dialog.Header>
|
|
|
|
<UnderlineTabs.Root bind:value={activeTab}>
|
|
<UnderlineTabs.List class="flex flex-wrap">
|
|
<UnderlineTabs.Trigger value="tutors">
|
|
{$t(`${INVITE_MODAL}.invite`)}
|
|
</UnderlineTabs.Trigger>
|
|
<UnderlineTabs.Trigger value="students">
|
|
{$t(`${INVITE_MODAL}.invite_students`)}
|
|
</UnderlineTabs.Trigger>
|
|
</UnderlineTabs.List>
|
|
|
|
<UnderlineTabs.Content value="tutors">
|
|
<div class="space-y-3">
|
|
<TutorSelectSection
|
|
{tutors}
|
|
bind:selectedIds
|
|
isLoading={orgApi.isLoading}
|
|
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
|
/>
|
|
|
|
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
|
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
|
|
{$t(`${INVITE_MODAL}.finish`)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</UnderlineTabs.Content>
|
|
|
|
<UnderlineTabs.Content value="students">
|
|
<div class="space-y-6">
|
|
<ExistingStudentsSection
|
|
students={availableStudents}
|
|
isLoading={isLoadingStudents}
|
|
onSearchValueChange={handleStudentSearch}
|
|
onAssign={assignExistingStudents}
|
|
/>
|
|
{#if $isStudentLimitReached}
|
|
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
|
|
{/if}
|
|
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
|
|
</div>
|
|
</UnderlineTabs.Content>
|
|
</UnderlineTabs.Root>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|