* 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.
109 lines
3.8 KiB
JSON
109 lines
3.8 KiB
JSON
{
|
|
"name": "@cio/db",
|
|
"version": "0.0.1",
|
|
"type": "module",
|
|
"main": "./dist/index.js",
|
|
"types": "./dist/index.d.ts",
|
|
"exports": {
|
|
".": {
|
|
"types": "./dist/index.d.ts",
|
|
"default": "./dist/index.js"
|
|
},
|
|
"./types": {
|
|
"types": "./dist/types.d.ts",
|
|
"default": "./dist/types.js"
|
|
},
|
|
"./drizzle": {
|
|
"types": "./dist/drizzle.d.ts",
|
|
"default": "./dist/drizzle.js"
|
|
},
|
|
"./schema": {
|
|
"types": "./dist/schema.d.ts",
|
|
"default": "./dist/schema.js"
|
|
},
|
|
"./auth": {
|
|
"types": "./dist/auth.d.ts",
|
|
"default": "./dist/auth.js"
|
|
},
|
|
"./utils": {
|
|
"types": "./dist/utils/index.d.ts",
|
|
"default": "./dist/utils/index.js"
|
|
},
|
|
"./queries": {
|
|
"types": "./dist/queries/index.d.ts",
|
|
"default": "./dist/queries/index.js"
|
|
},
|
|
"./queries/query-errors": {
|
|
"types": "./dist/queries/query-errors.d.ts",
|
|
"default": "./dist/queries/query-errors.js"
|
|
},
|
|
"./queries/*": {
|
|
"types": "./dist/queries/*/index.d.ts",
|
|
"default": "./dist/queries/*/index.js"
|
|
},
|
|
"./queries/*/*": {
|
|
"types": "./dist/queries/*/*.d.ts",
|
|
"default": "./dist/queries/*/*.js"
|
|
}
|
|
},
|
|
"files": [
|
|
"dist"
|
|
],
|
|
"scripts": {
|
|
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
|
|
"dev": "tsc --watch",
|
|
"db": "drizzle-kit",
|
|
"db:studio": "drizzle-kit studio",
|
|
"db:generate": "drizzle-kit generate",
|
|
"db:migrate": "drizzle-kit migrate",
|
|
"db:baseline": "tsx src/scripts/baseline.ts",
|
|
"db:reset": "tsx src/scripts/db-reset.ts --skip-seed",
|
|
"db:setup": "tsx src/scripts/db-setup.ts",
|
|
"db:setup:seed": "tsx src/scripts/db-setup.ts --seed",
|
|
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
|
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
|
"migrate:programs-to-cohorts": "tsx src/scripts/migrate-programs-to-cohorts.ts",
|
|
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
|
|
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
|
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
|
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
|
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
|
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
|
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
|
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
|
"seed": "tsx src/scripts/seed.ts",
|
|
"format": "prettier . --write",
|
|
"db:supabase-to-betterauth": "tsx src/scripts/supabase-to-betterauth.ts",
|
|
"db:dump-backup": "tsx src/scripts/pg-dump-backup.ts",
|
|
"db:push-dump-to-new-db": "tsx src/scripts/push-dump-to-new-db.ts",
|
|
"db:restore-lesson-section-from-dump": "tsx src/scripts/restore-lesson-section-from-dump.ts",
|
|
"db:restore-organizationmember-from-dump": "tsx src/scripts/restore-organizationmember-from-dump.ts",
|
|
"db:disable-v1-content-grouping-from-dump": "tsx src/scripts/disable-v1-content-grouping-from-dump.ts",
|
|
"db:fix-landing-courses": "tsx src/scripts/fix-landing-page-courses-links.ts"
|
|
},
|
|
"dependencies": {
|
|
"@better-auth/sso": "^1.4.18",
|
|
"@cio/email": "workspace:*",
|
|
"@cio/question-types": "workspace:*",
|
|
"@cio/tsconfig": "workspace:*",
|
|
"@cio/utils": "workspace:*",
|
|
"bcrypt": "6.0.0",
|
|
"better-auth": "^1.4.18",
|
|
"drizzle-kit": "^0.31.8",
|
|
"drizzle-orm": "^0.44.7",
|
|
"jose": "^6.1.3",
|
|
"postgres": "^3.4.7"
|
|
},
|
|
"devDependencies": {
|
|
"@types/bcrypt": "6.0.0",
|
|
"@types/node": "^24.8.1",
|
|
"@types/pg": "^8.16.0",
|
|
"dotenv": "^17.2.3",
|
|
"pg": "^8.18.0",
|
|
"tsc-alias": "^1.8.16",
|
|
"tsx": "^4.20.3",
|
|
"typescript": "^5.9.3"
|
|
}
|
|
}
|