b599f1a017d4f31f3627a4456be5222d4d95622d
226
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b599f1a017 |
chore(docs): migrate the docs site from Fumadocs to Blume (#753)
* Update documentation for ClassroomIO: change Fumadocs to Blume, revise MDX format conventions, and clarify sidebar navigation requirements. * docs: update README and scripts for improved preview and error handling * docs: update Discord link in configuration and documentation * docs: add token-auth route and update documentation formatting * Fix formatting in auto-comment-issues.yml * Fix JSON syntax by adding a comma * fix(docs): declare Blume's Astro build deps so Cloudflare build resolves them blume build generates an Astro project under apps/docs/.blume/ that imports astro, @astrojs/mdx, @tailwindcss/vite, tailwindcss, @tailwindcss/typography, @shikijs/twoslash and @scalar/astro. Under pnpm's isolated node_modules these are nested inside blume and not resolvable from the docs app, so a clean --frozen-lockfile build (Cloudflare) fails at 'Cannot find module astro/config'. Declare them directly in @cio/docs, pinned to blume's resolved versions. * refactor(docs): update API reference paths and documentation for clarity * fix: change platofrm url to docs home page * Refactor documentation for self-hosting ClassroomIO - Removed the Supabase Cloud setup guide as it is no longer needed. - Updated the Enterprise SSO documentation to include a reference link for SSO & licensing. - Added new documentation for Content Security Policy (CSP) configuration. - Created a new section for configuring SMTP for email notifications. - Introduced a comprehensive configuration index for self-hosted ClassroomIO, detailing environment variables and their usage. - Added documentation for optional integrations including Google OAuth, OpenAI, and Unsplash. - Documented the generation and usage of required secrets for ClassroomIO. - Created a section for SSO & licensing, detailing the requirements for Enterprise features. - Added object storage configuration documentation, covering both bundled MinIO and external S3-compatible services. - Documented versioning and upgrade processes for ClassroomIO. - Updated the Coolify and Docker self-hosting guides to streamline configuration references and upgrade instructions. - Enhanced the Railway self-hosting guide with links to relevant configuration sections. - Updated token authentication documentation to reference SSO & licensing. * Add comprehensive guides for ClassroomIO features and functionalities - Introduced a new guide for the Student Dashboard, detailing navigation and features available to students. - Added a guide on using Tags for organizing and filtering courses within ClassroomIO. - Created a guide on how to take attendance in courses, including marking attendance and related functionalities. - Developed a Terminology guide to clarify key terms used throughout ClassroomIO. - Implemented a Token Auth guide for signing users into ClassroomIO from external applications using JWT. - Added instructions for using math in the editor with LaTeX support. - Created a guide for customizing the welcome email sent to students upon enrollment in courses. - Added a guide explaining what ClassroomIO is and its key features. - Updated the home documentation to provide a clearer overview of ClassroomIO and its resources. - Enhanced self-hosted configuration documentation with a new guide for deploying ClassroomIO on Dokploy. - Updated links in the self-hosted configuration documentation for better navigation. - Added new images to support the Dokploy deployment guide. - Updated asset packaging script to handle redirects for the new guide structure. - Fixed footer link to point to the correct Getting Started guide. * chore: uprage version * feat(docs): add DigitalOcean deployment guide and flatten guides structure - Add a guide for deploying ClassroomIO on DigitalOcean using Docker Compose and Caddy for automatic HTTPS. - List DigitalOcean as a deployment option in the self-hosting index. - Move guides out of content/docs/guides into content/docs (student-dashboard, tags, terminology, token-auth, what-is-classroomio, audience, cohorts, community, course, enterprise-sso, mcp, organization, quickstart/*). - Rename home.mdx to index.mdx. - Update package-assets script to emit redirects for the old guides paths. --------- Co-authored-by: Rotimi Best <best@classroomio.com> |
||
|
|
0f23453f91 |
fix(api): use admin app URL for teacher auto-enroll email links (#771)
* fix(api): use admin app URL for teacher auto-enroll links Teacher payment-request emails now build course and grant-access URLs with getAppBaseUrl() instead of getDashboardBaseUrl(org), so links point at app.classroomio.com rather than a custom or tenant domain. Document the teacher vs student email link rule in AGENTS.md. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: surface email link URL rules where emails are wired Document getAppBaseUrl vs getDashboardBaseUrl in dashboard-url.ts, packages/email/README.md, enqueueTransactionalEmail JSDoc, and AGENTS.md so staff vs learner hosts are discoverable when building email links. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
0822f5f5a3 |
fix(dashboard): course landing page editor section and currency bugs (#768)
* fix(dashboard): course landing page editor section and currency bugs - Raise currency select z-index so the dropdown is clickable in the full-screen editor shell - Show dashed top/bottom borders and section title on hover and when active in EditableLandingSection - Map hero/header section keys in the course editor preview context Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): format course landing page pricing with Intl currency Use getCourseCurrencyFormatter (matching dashboard getCurrencyFormatter) so NGN and USD prices render as proper locale currency strings instead of raw numbers with a currency code suffix. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): prefix group class for landing section hover badge Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): remove unused formatCourseCurrency helper Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor: move currency formatter to @cio/utils Shared course price formatting belongs in @cio/utils, not the UI package. Dashboard re-exports the helper to keep existing imports. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- |
||
|
|
6c3a982daf |
feat(course): auto-enrol button for paid course join requests (#769)
* feat(course): add auto-enrol flow for paid course join requests - Extend teacher buy-request email with manual access steps and an Auto Enrol this Student button - Link teachers to the course People page with grantAccess query param - Add grant-access modal that sends the same org audience invite used by the Invite new students flow Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(i18n): translate grant access modal strings Replace English placeholders copied into non-en locale files with proper translations for each supported language. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): shorten auto-enrol button label per review Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): use American spelling Enroll in auto-enroll CTA Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(i18n): localize Danish grant-access send invite label Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
27229cf8fe |
fix: canonical exercise completion, teacher course completion & upsert member fixes (#767)
* fix(db): preserve not-found error and dedupe member lookup in upsert (#763) Re-throw Organization member not found so the service layer can return 404. Fetch existing notification prefs by resolved member id instead of calling getOrganizationMemberEmailNotifications, which repeated the lookup. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(course): single source of truth for exercise completion; let teachers complete courses Exercise completion was computed four different ways (course progress, enrolled courses, content items, progression locks), and two of them counted any submission as complete, ignoring the exercise's 'passed' completion policy and pass threshold. My Learning and certificate eligibility could therefore report 100% while the course itself showed an unpassed exercise. - Add isExerciseCompletedSql, a policy-aware SQL predicate in the db query layer, and route every completion computation through it (getCourseProgress, getEnrolledCourses, getCourseContentItems, getCompletedExerciseIdsForMember, isExerciseCompletedForMember). - Remove the per-exercise N+1 pass checks in progression and the duplicated policy interpretation in the submission service. - Fix infinite "Checking eligibility" modal on the certificates page: open the completion modal in its final state instead of 'checking' with no follow-up. - Align teacher experience with students: certificate eligibility no longer requires the student role, completion modal triggers for any enrolled member, teachers can view/download their earned certificate, and video-watch progress tracks in the student experience regardless of org role. * chore: run api:dev without prebuild; keep full build as api:dev:full * fix(dashboard): gate video watch tracking on learner view * feat: pr reviews --------- Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
f3d1a885b9 |
feat(notifications): per-org member email preferences table (#751)
* feat(notifications): add email notification preferences Implement the email notification preferences PRD with org-level defaults and personal opt-outs enforced at the email enqueue chokepoint. - Add toggle map, profile.settings jsonb, and shouldSendEmail resolver - Extend profile and organization update APIs with emailNotifications - Pass preference context through all toggleable email send call sites - Add Settings > Notifications page with personal and org admin sections Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(notifications): group email toggles into course, cohort, and enrollment sections Organize personal and org notification settings into scannable sections so users can find course- or cohort-related emails quickly. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): use Exercise instead of Quiz in toggle copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): only mark certification email sent when enqueued Skip setMemberCertificationEmailSent when notification preferences or missing Redis prevent the completion email from being queued, so the email can still send if preferences change later. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): address PR review feedback - Add EmailPreferenceLookupCache to avoid N+1 settings lookups in batch processors and multi-recipient email enqueue paths - Replace $effect store hydration in notifications settings with onMount and explicit sync after save - Remove thin api email-preferences re-export; import from db queries - Hide tutor-only personal toggles (newStudent, newSubmission) for students Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): hydrate toggles after store load and add profile settings migration - One-time store hydration via onMount plus guarded effect when profile/org load after mount, without clobbering in-progress edits - Add drizzle migration for profile.settings jsonb column Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(notifications): add email preferences to LMS settings Students manage personal email opt-outs from /lms/settings/notifications, matching the org settings page but scoped to the LMS navigation students use. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(notifications): store per-org personal prefs in organizationmember_email_notifications - Add organizationmember_email_notifications table keyed by organizationmember - Expose GET/PUT /organization/member/email-notifications for current org member - Resolve personal email prefs by profileId + organizationId at send time - Migrate legacy profile.settings.emailNotifications into per-membership rows - Update org and LMS settings UI to load/save org-scoped member preferences Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): address PR review on locales and backfill script Move profile email-notification backfill out of the migration SQL into a dedicated db script with dry-run support. Translate personal preference description strings in all non-English locale files. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(i18n): translate notification preference strings in all locales Complete settings.notifications and settings.tabs.notifications_tab translations for da, de, es, fr, hi, pl, pt, ru, and vi. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
c9b7c79365 |
fix(billing): downgrade orgs when Polar subscription is revoked (#759)
* fix(billing): downgrade orgs when Polar subscription is revoked The Polar webhook handler had subscription.revoked as a no-op, so subscriptions that expired or failed to renew never got the org's organization_plan row deactivated, leaving them stuck on a paid plan indefinitely. Handle subscription.revoked the same way as an inactive subscription.updated event. Also add a one-time backfill script (db:downgrade-expired-subscriptions) that re-checks every currently-active org plan against Polar directly and downgrades any whose subscription already ended, to fix orgs affected before this webhook fix shipped. * Update packages/db/src/scripts/downgrade-expired-subscriptions.ts --------- |
||
|
|
3980aa0a9b |
feat(dashboard): restyle lesson transcript and summarize actions (#758)
* feat(dashboard): restyle lesson transcript and summarize actions Add separator lines around lesson material actions below the video, switch transcript and summarize controls to plain ghost buttons with hover-animated icons, and separate the two actions with a pipe when both are visible. Display rules are unchanged. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(dashboard): inline summarize action into material actions Fold lesson-summarize-button into lesson-material-actions and remove the standalone component. Lessons without videos use the same actions bar with separators disabled. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): align lesson actions with note width on mobile Constrain material actions to max-w-2xl when there is no video so they match the note column. Improve small-screen layout with tighter padding, flex-wrap, and hide the pipe separator below the sm breakpoint. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * add script update * fix(dashboard): tighten spacing between lesson video and note Remove gap-2 from the lesson content stack and drop bottom margin on the last video wrapper so material actions separators control the gap before the note. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): show bottom separator on note-only lesson actions Lessons without video were passing showSeparators=false, hiding both lines. Split into top/bottom controls so summarize still gets a line before the note without one above the action row. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): always show top separator on lesson material actions Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): reduce lesson material action separator margin to my-2 Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(dashboard): consolidate multi-video transcript actions and navigation Register transcript sources per upload video, show a single material actions row after all videos, and add prev/next video navigation in the transcript side panel when multiple transcripts are available. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): address greptile review on lesson material actions Require lessonId on material actions and stop overwriting the transcript seek wrapper with a stale localSeekFn snapshot in the sync effect. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): stop transcript registry effect infinite loop Replace the reactive sync effect with explicit registry updates from loadTranscript and skip no-op transcript source writes to avoid reading and writing transcriptSources in the same effect pass. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): raise tooltip z-index above app bar side panel Use z-menu-elevated for tooltip content so header icon tooltips are not hidden behind the fixed transcript side panel (z-app-bar). Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
bfafc685e0 |
fix(exercise): persist completion policy and improve dev build workflow (#754)
* fix(exercise): persist completion policy and pass threshold on save buildExerciseUpdateFields accepted completionPolicy and passThreshold in the validation schema but never mapped them to the database update, so exercise settings appeared to save while reverting on refresh. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): build workspace deps before compiling @cio/db imports @cio/utils/notifications, which only exists after @cio/utils is built. Building db in isolation failed when utils dist was missing or stale. - Add a db prebuild step for question-types, utils, and email - Clean dist and tsbuildinfo before utils and email builds so incremental tsc cannot skip emission after dist was removed Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore(dev): build api workspace deps before api:dev Run turbo build for @cio/api and @cio/jobs-worker dependencies before starting the dev servers, so missing dist/ folders do not break startup. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(course): improve exercise progression grading UX --------- Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
fa060f5f6d |
feat(notifications): email notification preferences (#747)
* feat(notifications): add email notification preferences Implement the email notification preferences PRD with org-level defaults and personal opt-outs enforced at the email enqueue chokepoint. - Add toggle map, profile.settings jsonb, and shouldSendEmail resolver - Extend profile and organization update APIs with emailNotifications - Pass preference context through all toggleable email send call sites - Add Settings > Notifications page with personal and org admin sections Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(notifications): group email toggles into course, cohort, and enrollment sections Organize personal and org notification settings into scannable sections so users can find course- or cohort-related emails quickly. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): use Exercise instead of Quiz in toggle copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): only mark certification email sent when enqueued Skip setMemberCertificationEmailSent when notification preferences or missing Redis prevent the completion email from being queued, so the email can still send if preferences change later. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): address PR review feedback - Add EmailPreferenceLookupCache to avoid N+1 settings lookups in batch processors and multi-recipient email enqueue paths - Replace $effect store hydration in notifications settings with onMount and explicit sync after save - Remove thin api email-preferences re-export; import from db queries - Hide tutor-only personal toggles (newStudent, newSubmission) for students Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(notifications): hydrate toggles after store load and add profile settings migration - One-time store hydration via onMount plus guarded effect when profile/org load after mount, without clobbering in-progress edits - Add drizzle migration for profile.settings jsonb column Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- |
||
|
|
8bd07a524c |
fix(org): auto-enroll idempotency, signup enrollment, and pending invite gate (#729)
* fix(org): auto-enroll idempotency, pending invites, and tenant enrollment - Map Postgres unique violations to 409 CONFLICT instead of 500 - Link email-only roster rows before signup policy gates (preserve role) - Return pendingInvite when an active org invite exists; surface modal globally - Enroll at signup via create-profile hook when cio-org-id header is present - Gate client auto-enroll on session orgRoles and pending invite check - Throw 409 when org email row is linked to a different profile (Greptile) - Enforce free-plan student cap via assertStudentCapacityOrThrow on new enrolls - Hide student-only orgs from admin org switcher; redirect students off /org/[slug] - Route tutor/admin pending-invite accepts on tenant sites to app host Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * perf(dashboard): parallelize account and pending invite fetch on tenant bootstrap Start GET /account and GET /invite/organization/pending together when a logged-in user visits a tenant site without session membership. Auto-enroll still runs sequentially after both complete when no invite is found, then refetches /account if enrollment succeeds. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: address PR review — restore AppModeWatcher and drop signup ctx enrollment Better Auth database hooks do not receive frontend cio-org-id headers in ctx, so enrollFromSignupOrgContext could never work. Revert to client-side auto-enroll fallback and restore main's AppModeWatcher in the root layout. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(org): rename auto-enroll-student endpoint to auto-join POST /organization/auto-join replaces /auto-enroll-student to clarify this joins org membership, not course enrollment. Renamed service to autoJoinOrg. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
8bed9f6b4e |
refactor(billing): system email + single-query backfill for the student cap (#735)
* refactor(billing): make student-limit email a system email; tighten backfill query
- Fetch free-plan orgs over the student limit in a single query
(getFreePlanOrganizationsOverStudentLimit) instead of loading all orgs and
counting each in memory; the backfill script now just fans out admin emails.
- Report a dry-run summary: how many free orgs are over the limit, how many
with admins, total admin emails, and how many were skipped for having none.
- Reclassify the student-limit email as a system (ClassroomIO-branded) email
rather than org-branded — it's the platform telling an admin about their
plan. Drop the `branding` field and org masthead/theme; both callers updated.
- Reword the email to explain why it's sent (new students are being turned
away) and that it's throttled to once a day, so it doesn't read as spam.
- Document the system-vs-org-branded email distinction in AGENTS.md.
* feat(billing): send student-limit emails on milestone crossings, once each
Stop emailing admins on every blocked attempt. Instead notify once when the
org crosses a student-count milestone:
- 50% of the limit → new studentLimitApproaching email (heads-up).
- 100% (limit reached) → studentLimitReached email.
Crossings are detected in assertStudentCapacityOrThrow on the allowed path
(blocked attempts no longer email). "Already notified" is persisted on
organization.settings.studentLimitNotified so each milestone fires at most
once, ever — surviving restarts, no per-day approximation. The backfill script
uses the same flag, making reruns idempotent (skips already-notified orgs and
marks them after sending). Email copy updated to explain the trigger and that
it's a one-time-per-milestone message.
* fix(email): use z.url() and drop the frequency footnotes on student-limit emails
Address PR review: `upgradeUrl` uses `z.url()`; remove the "once a day / once
per milestone" footnote lines from both the reached and approaching emails.
* set track={false}
|
||
|
|
8288f157b3 |
fix(email): include course name in student welcome subject (#739)
* fix(email): include course name in student welcome subject The studentCourseWelcome template used a static subject, so bulk course assignments produced identical inbox titles. Resolve the subject from courseName at send time and sanitize it like other email headers. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): use course-only subject for student welcome email Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
51f90bf450 |
fix(dashboard): make upgrade plan modal mobile responsive (#736)
* fix(dashboard): make upgrade plan modal mobile responsive - Constrain modal height and enable scrolling on small screens - Anchor modal near top on mobile instead of vertical centering - Stack success-state buttons and use full-width cards in the grid - Reduce pricing card padding and adjust toggle badge on narrow viewports Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore(ui): regenerate output.css for pricing responsive classes Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
46feccd0fd |
fix(dashboard): restore paid course payment modal on landing page (#737)
* fix(dashboard): restore paid course payment modal on landing page Wire the existing PaymentModal back into the theme-based course landing page. Paid course CTAs now open the name/email modal and redirect to the configured payment link instead of navigating to the blocked enroll route. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): reset payment modal on close without effect loop Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): avoid double-applying discount on course landing pricing Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
fd8bec3e70 |
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.
|
||
|
|
0b5247bed3 |
fix(org): route admin and tutor invites to app domain (#731)
Team member invite emails and link-invite copy URLs were built with the org tenant host (custom domain or siteName.myclassroomio.com). Admin and tutor invites should always point at the admin dashboard origin (app.classroomio.com in cloud). Student org invites are unchanged. |
||
|
|
c03c08fc84 |
fix(email): make team org invites more transactional (#732)
* fix(email): make team org invites more transactional Use org-branded sender and subject instead of the default "Best from ClassroomIO" marketing identity. Include the inviter name in the body, remove promotional emojis, and thread optional subject overrides through the email job pipeline. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): escape invite fields and sanitize email headers Address Greptile review: escape inviter/org/role values in the team invite template, sanitize display names in buildEmailFromName, and strip control characters from dynamic subjects. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): use RFC 5322 backslash escaping in display names Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * test(email): add vitest coverage for email header helpers Cover escapeHtml, sanitizeEmailDisplayName, sanitizeEmailSubject, and buildEmailFromName. Collapse repeated whitespace when stripping control characters from header values. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(email): strip all ASCII control chars from email headers Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
0836d9b5bb |
refactor(z-index): introduce a shared named z-index scale (#723)
* refactor(z-index): introduce a shared named z-index scale Replace scattered magic z-index numbers (100, 150, 200, 201, 250) with named layers (z-app-bar, z-app-bar-elevated, z-modal, z-menu-elevated) so intent is documented at each call site and future additions insert predictably instead of colliding. Defined via Tailwind's @utility directive in two places that mirror each other: packages/ui/src/index.css (ui:-prefixed, for components that live in the @cio/ui package) and apps/dashboard/src/app.css (unprefixed, for dashboard-only files) — a ui:-prefixed custom utility only compiles if it is also used somewhere under packages/ui/src, so dashboard-only usages need the unprefixed twin. Also fixes two pre-existing dropdown/select menus (tags-admin.svelte, mark-exercise-modal.svelte) that used ui:z-[250], which never actually compiled to any CSS rule since nothing under packages/ui/src used that exact class. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): safelist the packaged z-index scale so it always compiles @utility only emits a class when Tailwind's scanner sees that exact candidate under packages/ui/src, so ui:z-app-bar-elevated and ui:z-menu-elevated were documented but silently missing from the compiled @cio/ui CSS since nothing inside the package itself used them. Safelist all four layers via @source inline() so the shipped scale always matches what's documented, regardless of which layers this package happens to reference internally. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
c0f82d9573 |
fix(auth): sync profile email verification with auth user (#726)
* fix(auth): sync profile email verification with auth user Repair stale profile rows where user.email_verified is true but profile.is_email_verified is false, which blocked the LMS with the verify-email modal while send-verification-email returned EMAIL_IS_ALREADY_VERIFIED. Sync on session login, account fetch, and user update; update the modal to respect session emailVerified and self-heal on that error. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore(db): add backfill script for stale profile email verification Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(db): document profile email verification backfill command Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(auth): never demote profile email verification on sync Greptile: self-hosted first signup can verify profile before user.email_verified is set. Sync now only promotes verification. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(auth): promote user.email_verified when profile is verified Self-hosted bootstrap and other flows can set profile.is_email_verified before user.email_verified. Sync and createProfileHook now update both tables when promoting verification. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
b888725125 |
fix(sidebar): scope elevated mobile z-index to course sidebar
Remove the global z-150 mobile sheet stacking from Sidebar.Root so LMS and org sidebars keep the default sheet layer. Course sidebar opts in via mobileOverlayClass and its existing z-150 class. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
035d976d14 | fix(dashboard): enroll verify flow and org-aware verification email (#718) | ||
|
|
553c126e3c |
fix(sidebar): raise mobile sidebar above sticky lesson headers
Restore lesson/exercise page header z-100 stacking and fix the overlap by lifting the mobile sidebar sheet and overlay to z-150, matching the desktop course sidebar. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
5de1f51e76 |
fix: address PR review comments on enroll and verify email
- Revert org-site layout 404 bypass; custom domain org lookup uses hostname - Remove unnecessary comment in enroll redirect helper - Restore email verification flow documentation - Use generic welcome copy when no org branding is resolved Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
a6d06f356f |
feat(email): make verify-email org-aware like other transactional mail
Resolve the tenant org from the verification link callback URL (custom domain, tenant subdomain, or ?org=) and apply org logo, theme color, from name, and subject. Falls back to ClassroomIO branding on the main app. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
be0085f090 |
fix(dashboard): save validation feedback, public site light mode, global verify modal (#716)
* fix(dashboard): surface save errors, force public site light mode, global verify modal - Show snackbar with first validation error when course update fails client-side validation - Force light mode on public org-site pages; lesson player and dashboard keep theme selector - Mount VerifyEmailModal in root layout for all authenticated routes; keep trigger=app email guard Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: instructor editor sync, auto-enroll after auth, email verification API - Initialize instructor form from org fallbacks and batch metadata updates - Auto-complete course enrollment after login/signup return from enroll page - Block new org auto-enroll and course enroll until email is verified Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: address PR review on instructor setter, color scheme, auto-enroll - Use existing setter with batched metadata.instructor updates in InstructorForm - Allowlist public org-site paths so /lms and /courses/:id keep theme selector - Auto-enroll logged-in users via Better Auth session; remove sessionStorage flow - Retry enrollment after email verification without duplicate Join Course clicks Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: allow enrollment without verification; gate dashboard only - Remove API email verification checks from enroll and auto-enroll flows - Move VerifyEmailModal to (app) layout so public org-site pages stay usable - Unverified users can enroll but hit the verification modal in LMS/dashboard Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: address PR feedback on verify modal, routes, instructor form - Restore currentOrg.id guard on VerifyEmailModal open condition - InstructorForm imports currentOrg directly instead of prop drilling - Move public org-site light-mode route patterns to routes constants - Reset enrollment attempt key on enroll failure to allow retry Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
35f5216839 |
fix(org): favicon fallback, OG logo styling, and Discord embed CDN URLs (#714)
* fix(org): serve OG images from CDN for Discord embeds Discord times out on slow dynamic OG generation (~3-5s). Persist generated images to R2, point meta tags at the media CDN, and warm the API endpoint on org-site page loads so crawlers get a fast static PNG URL. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: add org-site Open Graph and favicon guides Document user-facing sharing behavior and contributor architecture for dynamic OG images, favicon fallback, R2/CDN caching, and embed cache invalidation. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org-site): address PR review on OG CDN fallback and docs - Fall back to API OG URL when CDN object is not yet available (HEAD check) - Cancel warm-fetch response body to avoid connection pool exhaustion - Align tenant-router OG edge cache TTL with one-hour API revalidation - Fix favicon docs navigation and add Custom Domain tab screenshot - Replace Chiberry Artistry example with AI Academy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org-site): accept docker hostnames for OG warm URL Use isValidHttpUrl for PRIVATE_SERVER_URL so internal values like http://api:3081 work for SSR warm fetches in Docker Compose. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
b8fca607aa |
feat(org): dynamic open graph images for school sites (#709)
* feat(org): add dynamic open graph images for school sites Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png. Paying cloud orgs include their landing-page tagline; free plans show a Powered by ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(org): enable custom favicon upload and org-site rendering Allow org admins to upload a favicon from domain settings, persist it via PUT /organization, and render it on public org sites with logo fallback. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org): scope custom favicon to public org-site routes only Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(api): inline org-site OG rendering, remove @cio/og-images package Move template and render helpers into apps/api/src/utils/org-site-og.ts. Certificates stay in a shared package because they support multiple templates; org OG is a single API-only concern. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore: remove packages/og-images directory Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(api): mount org OG image under /org-site/og Align with existing org-site public routes (/org-site/course) instead of a top-level /org prefix. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(plans): consolidate isOrgOnFreePlan into options-based utility Move orgId guard and self-hosted/plan checks into @cio/utils/plans. Dashboard and API callers pass plans, isSelfHosted, and optional orgId directly; remove the dashboard store wrapper. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org): require orgId for free-plan check and translate favicon strings Treat missing orgId as not on free plan so nav gating matches prior behavior when currentOrg is unset. Pass organization.id from OG service. Add favicon settings translations for all non-English locales. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(dashboard): move meta tag helpers to metaTags.server.ts Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic private env imports cannot leak into universal or client bundles. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
bce38aa530 |
fix(dashboard): show course cards in landing page settings preview (#697)
* fix(dashboard): show course cards in landing page settings preview Fetch public courses for the org and pass them to the theme preview on the landing page settings screen, matching the editor preview. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(dashboard): skip redundant public course fetches Add loadPublicCoursesIfNeeded so settings, editor, and live landing page reuse cached courses for the same org site instead of clearing and refetching on every mount. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): always refetch courses on live org landing page Keep loadPublicCoursesIfNeeded for settings preview and editor only. The public-facing landing page should fetch fresh on each visit. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(ui): add shared empty state for org landing page catalogs Introduce OrgLandingPageCoursesEmpty using the dashboard Empty pattern with landing-theme colors. Show it across all ten org landing templates when no courses are published, and wire translated labels through buildOrgLandingPageProps. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): address Greptile review on public courses cache - Add coursesLoaded prop so empty catalog state waits for fetch - Deduplicate concurrent public course requests via in-flight promise - Refresh courses on settings mount; reuse cache in editor only - Expose invalidatePublicCourses for explicit cache clearing Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): load landing preview courses when org store hydrates Replace onMount course fetch with a reactive effect keyed on the route slug and currentOrg.siteName so public courses load after app init. Mark the fetch as complete in finally so failed requests show the empty state instead of a permanently blank catalog section. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- |
||
|
|
0ed0e3864d |
fix(api): return Zod validation errors from public API routes (#708)
* fix(api): return Zod validation errors from public API routes Public API handlers now surface ZodError details as 400 VALIDATION_ERROR responses instead of generic 500 INTERNAL_ERROR payloads. Course import services also rethrow ZodError so validation failures are not wrapped as server errors. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(utils): centralize course type values without db dependency Define COURSE_TYPE_VALUES once in @cio/utils/constants and use it for ZCourseType validation and the Drizzle COURSE_TYPE pgEnum. Removes the circular @cio/utils -> @cio/db import while keeping course-import schemas aligned with all four course types including PUBLIC. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(validation): default missing course metadata allowNewStudent to true Courses stored without allowNewStudent in metadata failed structure export validation. Align Zod preprocessing with runtime behavior where absent values mean enrollments are open. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore(db): drop unused COURSE_TYPE_VALUES re-export from schema Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
a337363965 |
fix(ui): respect iframe width and height in org landing embed section (#706)
Embed iframes were placed inside flex column layouts without shrink protection, so authored width/height attributes could be ignored when the preview panel was narrower than the iframe. Parse dimensions from pasted embed code and size a dedicated host wrapper so the iframe renders at the intended dimensions while still scaling down with max-width: 100%. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
ec61bfe7d9 |
feat(cohorts): rename Programs to Cohorts with data migration (#694)
* feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
fe9f8c441f |
feat(api): public API plan gating, docs auth, and dashboard snippets (#701)
* fix(automation): align public API plan gating across cloud and self-hosted Add shared canUsePublicApi helper so cloud allows Early Adopter and Enterprise while self-hosted requires Enterprise. Update the API key dashboard page and backend key creation checks to use the same rules. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(api): document bearer auth on public API reference Add shared OpenAPI enrichment with bearerAuth security scheme and authentication instructions. The interactive reference now loads an enriched spec with Authorize support, and the API guide explains how to create and use org API keys. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(dashboard): add API quick-start code snippets on automation page Mirror the MCP client setup section with cURL, JavaScript, and Python examples that show how to authenticate public API requests with org keys. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * ci(openapi): republish spec when public API OpenAPI helpers change Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * feat(dashboard): add Go snippet to API quick-start examples Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(api): use /org/*/api and clarify dashboard key generation path Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(api): escape backticks in OpenAPI description template literal Greptile flagged that unescaped backticks around * in PUBLIC_API_OPENAPI_DESCRIPTION evaluated to NaN and dropped auth docs from the Scalar spec. Also use plan-specific snackbar message on upgrade. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
447e54b1f4 | chore(db): add script to fix landing page #courses links and Programs labels | ||
|
|
78a2b85ad3 |
feat(courses): add type, search, and pricing filters to org landing page
- Add Types section to the Filter sidebar with all 4 course types - Add full-text search input centered in the hero (title + description) - Add Pricing section (Free / Paid) based on course cost - Compact hero prop across all 10 themes to halve vertical padding - Children snippet support in all hero themes for the search input - All filters are URL-driven (?types=, ?search=, ?tags=, ?pricing=) - Full-stack: validation schema, DB queries, service, API route, server load, UI |
||
|
|
8d2f2a6d2d |
fix(landing-page): render iframe embed codes instead of stripping them
The embed section passed code through SafeHtmlContent which ran DOMPurify with FORBID_TAGS including 'iframe', silently removing the element. Replaced SafeHtmlContent with a new sanitizeEmbedHtml() that uses a whitelist of ALLOWED_TAGS: ['iframe'] and restricts src to https: only, so Google Forms, YouTube, and other https embeds render while javascript: sources and non-iframe tags are still blocked. |
||
|
|
c72558f8f1 |
fix(course): treat null isUnlocked as unlocked in progression logic
Items with isUnlocked = null were evaluated as teacher-locked due to `?? false` defaulting to false before negation. This caused content to appear locked immediately after a student marked a lesson complete, even in free-mode courses. Changed to `?? true` in both client-side (progression-utils.ts) and server-side (core/progression.ts) so null/undefined isUnlocked defaults to unlocked — consistent with the sidebar's own `?? true` guard. |
||
|
|
a61942feb9 |
feat: configurable upload file size limits via .env (#695)
* feat: make upload file size limits configurable via .env Add UPLOAD_MAX_*_MB environment variables for documents, images, videos, exercise assignment files, AI assistant documents, landing page images, and media thumbnails. API presign routes validate file size server-side; dashboard UI reads the same limits from layout data. * chore: wire upload limit env vars through Docker compose and docs Pass UPLOAD_MAX_*_MB from the root .env into api and dashboard containers in both compose files, document them in .env.example and self-hosting guides. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: cross-link upload limits in README, Railway, Coolify, and Cloudflare guides Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: address PR review feedback on upload limits - Restore 5 MB default for thumbnail uploads (edit-asset-dialog regression) - Add getResolvedUploadLimits() fallback for Storybook/tests - Cache core getUploadLimits() like the dashboard helper - Document presign fileSize as advisory; allow zero-byte fileSize in schema - Only send fileSize to presign when greater than zero * docs: remove upload limits from Cloudflare setup guide --------- |
||
|
|
65420320e7 |
feat(ai-assistant): odt support, document-to-lesson attach, upload UX fixes
- Add .odt (application/vnd.oasis.opendocument.text) as a supported upload format; extract text via JSZip + content.xml parsing - Add attach_document_to_lesson agent tool so the AI can attach an uploaded document to a lesson as a downloadable material - Instruct the agent to create a "Course Materials" lesson (first lesson of the first section) and attach the uploaded document to it on implementation - Suppress hallucinated references: uploaded documents do not count as fetched URLs — omit the References section when no fetch_documentation_url calls were made - Surface specific upload error snackbars (file_too_large, unsupported_file_type) instead of silently logging; fix generic fallback message - Allow typing in the chat textarea while a document is uploading; only block Enter/send, not the whole input - Fix retry endpoint to return the current run instead of erroring when the run is already active (queued/running/waiting_for_input/paused) - Connect @cio/core Redis singleton in the jobs bootstrap so course-generation workers no longer fail with "The client is closed" - Expose documentAssets (assetId + fileName) alongside documentText in AgentContext and pass it through both the chat route and course runner |
||
|
|
513ab5e81f |
fix(hls): scope cookie name per-asset to prevent multi-video 403s
When a lesson has two or more uploaded videos each calls mintHlsCookie,
but all wrote to the same `cio_hls` cookie name. The last write won,
leaving every other video with a mismatched token and a 403 from the
CF Worker.
Cookie name is now `cio_hls_${assetId}` so each video owns its own
cookie. The tenant-router Worker, the Hono HLS fallback route, and the
transcripts route all read the per-asset name.
|
||
|
|
a41cc38b7b |
fix(ui): deduplicate seek-blocked notification in video player
Replace the per-handler boolean guard with a shared 300ms cooldown timestamp so all three Plyr seek-enforcement paths (progress-bar listener, seeking, seeked) produce at most one snackbar per skip attempt. |
||
|
|
55abf982ed |
fix(question-types): realign registry ids with the question_type table
The registry hardcoded numeric ids that drifted from the database `question_type` table: MATCHING and HOTSPOT were inserted mid-list (ids 9 and 11) but never seeded, shifting ORDERING/LINK/WORD_BANK/ STAR/VIDEO_RECORDING one or two ids past their real rows. Every id emitter inherited the wrong values — the exercise editor, the AI question generator, and the grading/scoring gate — so e.g. an ORDERING question was stored as id 10 (LINK in the DB), came back as a manual type, and never auto-graded. - Move MATCHING/HOTSPOT to the end (ids 14/15) and flag them `disabled`, restoring the append-only id convention and realigning ORDERING=9, LINK=10, WORD_BANK=11, STAR=12, VIDEO_RECORDING=13 with the DB. - Add a registry test asserting every enabled type's id matches the canonical `question_type` rows and disabled types stay past the live range, so ids can't silently drift again. - Constrain the agent tool schema to enabled, DB-backed ids so the model can't emit a disabled id with no row. - Drop disabled types from the teacher prompt's type list and guidance. - Centralize the dashboard question-type picker on the `disabled` flag. This also fixes video-recording detection, which compared against the stale id 15 instead of the real 13. |
||
|
|
eb4cd22ac5 |
chore(db): add migration dropping position from asset_usages unique key
Generated migration for the narrowed asset_usages_asset_target_slot_unique constraint (asset_id, target_type, target_id, slot_type, slot_key). Run the duplicate cleanup before applying — existing rows that differ only by position will otherwise violate the new constraint. |
||
|
|
f18e5600f6 |
fix(media): prevent attaching an asset to a target twice
Drop `position` from the `asset_usages` unique constraint so the same asset can't be attached to one target/slot more than once, and update the insert's onConflict target + lookup to match the narrower key. Guard it at the service layer too: attachAssetService now rejects an attach when the asset already has a usage for that target with a new ASSET_ALREADY_ATTACHED (409) error, backed by an assetUsageExistsForTarget query. |
||
|
|
a70f8c5287 | fix: hls session ending and user can't watch video | ||
|
|
08ea608f94 |
feat(media): delete videos from the media library
Add an admin flow to permanently delete a media asset and clean up everything it leaves behind. - Delete dialog with a destructive, irreversible confirmation; deletion is blocked while the video is still attached and shows the in-use lessons so the admin can detach first. - Usage list resolves lesson/course context for each attachment, links to the lesson editor via an anchor (right-click "open in new tab"), and exposes a refresh button to re-check usage after detaching elsewhere. - Storage cleanup runs as a background maintenance job: the producer captures the asset's object keys/prefixes up front and an idempotent worker sweeps HLS segments, thumbnails, audio, transcript, and the raw upload from object storage. Also harden usage accounting so stale attachments cannot trap an asset: - asset_usages has no FK to its polymorphic target, so prune a lesson's usage rows whenever the lesson is hard-deleted (single + bulk paths). - Usage resolution drops orphaned lesson usages (lesson gone or course soft-deleted) so they neither appear in the list nor block deletion. Document the icon-only button convention (variant="secondary") in AGENTS.md. |
||
|
|
a695c97eda |
feat(course): reset student course progress (#671)
* feat(course): add reset student course progress action Give course admins and tutors a destructive reset-progress flow on the student people detail page. The backend clears learner progress for one (courseId, memberId) pair in a single transaction while keeping the groupmember enrolled, and logs an audit summary. The dashboard shows a confirmation dialog with impact counts from analytics before deleting. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(i18n): add localized reset progress strings for all locales Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(course): scope reset progress to course data only Stop deleting org-level community questions and answers during course progress reset. Fix course newsfeed deletion order so comments on a student's posts are removed before the posts themselves. Restrict reset to student members, move analytics loading to the person detail layout, and rename impact copy to course newsfeed only. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(course): address Greptile review on reset progress Make progressImpact opt-in via includeProgressImpact query param so analytics requests avoid extra DB work unless the reset dialog needs it. Count only student-authored course newsfeed posts and comments in the impact summary to avoid double-counting replies on the student's posts. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> |
||
|
|
23dba7392e | Merge branch 'main' of https://github.com/classroomio/classroomio | ||
|
|
50a4f24b7c | fix: encode body of translation updating, reduce p margin in prose | ||
|
|
6d594b4296 |
feat: harden self-hosting + add pull-based deploy, Coolify/Railway (#666)
* feat: enhance self-hosting support and CI/CD workflows - Updated .env.example to clarify auth secret generation and self-hosting mode. - Added CI workflow for build and lint checks on PRs and main branch pushes. - Improved docker-publish workflow to trigger on relevant file changes and added multi-arch support. - Enhanced upload-openapi-spec workflow with caching for dependencies. - Removed obsolete Dockerfile for API service and added jobs worker to Docker setup. - Updated self-host documentation to reflect new jobs worker requirement. - Modified Dockerfiles for API and Dashboard to optimize dependency fetching and installation. - Enhanced docker-compose.yaml with health checks and jobs worker configuration. - Updated docker-push.sh to support multi-architecture builds and improved logging. - Revised documentation for publishing images and self-hosting to include new features and requirements. * feat(self-host): safe versioned releases + pull-based image pipeline Builds on the pull-based images compose (docker-compose.images.yaml) to make pulling pre-built images safe for self-hosters: - docker-publish: stable-releases + edge tag policy. Git tag vX.Y.Z publishes X.Y.Z/X.Y/X/latest; a push to main publishes only :edge (latest never moves on a routine merge). - Pre-publish smoke-test gate: each image is booted against ephemeral postgres+redis and healthchecked before any push (docker/smoke-test.sh). - CIO_VERSION pinning in .env.example; run-docker-full-stack.sh --images flag to pull instead of build. - Docs: SELF_HOST pull-from-images + versioning/upgrade/rollback sections, PUBLISHING_IMAGES tag policy + pnpm release flow, self-hosting.mdx quickstart. * feat: enhance self-hosting support and CI/CD workflows - Updated .env.example to clarify auth secret generation and self-hosting mode. - Added CI workflow for build and lint checks on PRs and main branch pushes. - Improved docker-publish workflow to trigger on relevant file changes and added multi-arch support. - Enhanced upload-openapi-spec workflow with caching for dependencies. - Removed obsolete Dockerfile for API service and added jobs worker to Docker setup. - Updated self-host documentation to reflect new jobs worker requirement. - Modified Dockerfiles for API and Dashboard to optimize dependency fetching and installation. - Enhanced docker-compose.yaml with health checks and jobs worker configuration. - Updated docker-push.sh to support multi-architecture builds and improved logging. - Revised documentation for publishing images and self-hosting to include new features and requirements. * ci: run build-only on PRs, defer lint until repo-wide format * docs: point Railway template link at the new template URL * fix: address Greptile review + dashboard CI build env - docker-push.sh: only also-tag :latest for real semver releases, so VERSION=edge/main/dev can't clobber the stable latest pointer (P1). - run-docker-full-stack.sh: stop deriving OBJECT_STORAGE_PUBLIC_ENDPOINT from the dashboard origin (broke presigned URLs for videos/documents buckets); derive only the public media base URL and document the S3-API route. - ci.yml: provide PUBLIC_IS_SELFHOSTED at build time (dashboard reads it from SvelteKit's strict $env/static/public); bump cache actions v3 -> v4. - upload-openapi-spec.yml: bump cache actions v3 -> v4. - smoke-test.sh: dashboard gate now fails on 5xx instead of accepting any response. * ci: scope PR build gate to the api + dashboard closures Building every package surfaced pre-existing rot in apps that aren't part of this PR (storybook's missing @lucide/svelte import, course-app prettier debt). The gate's purpose is to verify the two Docker-image apps; the rest are built on PRs by their own Cloudflare Workers builds. Filter to @cio/api... and @cio/dashboard... so the gate covers exactly what it should. * fix(docker-push): make latest_arg always return 0 Use an `if` instead of `is_release_version && printf` so the helper returns 0 even when it emits no tag. A non-zero return inside the $(latest_arg ...) build argument is fragile under set -e (and would abort with inherit_errexit), which would break the edge/main builds the policy explicitly supports. * fix(docker): copy patches/ into jobs image so pnpm install resolves patchedDependencies The jobs Dockerfile ran `pnpm install` without copying patches/, so pnpm failed hashing package.json#pnpm.patchedDependencies (layerchart patch), breaking the self-host full-stack build. api/dashboard already copy patches/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(docker): build jobs worker deps in topological order The jobs Dockerfile built workspace packages with a hand-written list that omitted transitive deps (e.g. @cio/question-types, consumed by @cio/utils), so `tsc` failed with TS2307 during the self-host build. Use the recursive `pnpm -r --filter @cio/jobs-worker... build` like the api/dashboard images, which builds the full dependency closure in the correct order. * refactor(docker): move jobs Dockerfile to docker/Dockerfile.jobs and align dep install Move apps/jobs/Dockerfile -> docker/Dockerfile.jobs so all three image recipes live together in docker/, and update every reference (docker-compose.yaml, docker-push.sh, docker-publish.yml, railway.jobs.json, self-hosting.mdx). Also align its dependency install with the api/dashboard images: replace the hand-maintained list of workspace package.json copies (which silently missed transitive deps like @cio/core / @cio/ai-assistant, leaving their node_modules uninstalled and breaking the build with TS2307) with the proven pnpm-fetch pattern: fetch the full lockfile into a cached store, COPY . ., then an offline filtered install of @cio/jobs-worker... This installs the complete dependency closure regardless of how the workspace graph grows. * refactor(docker): move docker-compose.yaml to repo root for Coolify compatibility Coolify's Docker Compose build pack forces Base Directory to the repo root and resolves build contexts from there, so the compose's `context: ..` (needed when the file lived in docker/) climbed above the repo and failed with `lstat /artifacts/docker: no such file or directory`. Move docker/docker-compose.yaml -> ./docker-compose.yaml and change the build contexts from `..` to `.` so the context is the repo root with no upward path. This works identically for run-docker-full-stack.sh, manual `docker compose up`, and Coolify. Dockerfiles stay referenced as docker/Dockerfile.*. Update the references in run-docker-full-stack.sh and the docs accordingly. The images compose (docker-compose.images.yaml) is unaffected (no build context) and stays in docker/. * Add self-hosting documentation for Coolify and Railway - Created new documentation for deploying ClassroomIO on Coolify using a pull-based Docker Compose stack. - Added comprehensive guide for self-hosting ClassroomIO with Docker, covering both pre-built images and building from source. - Introduced a new meta.json file to organize self-hosting documentation pages. - Developed a Railway deployment guide with environment variable configurations for API, dashboard, and jobs worker. - Updated self-hosting blog post to link directly to the new Railway guide. - Removed obsolete Railway configuration files (railway.api.json, railway.dashboard.json, railway.jobs.json). - Enhanced run-docker-full-stack.sh script to improve MinIO credential generation and service readiness checks. - Updated docker-compose files to enforce strong secret requirements for authentication. - Adjusted self-hosting references in the Docker documentation to include new guides. * fix(docker): point references at root-level docker-compose.images.yaml The pull-based compose file moved to the repo root (alongside docker-compose.yaml). Update IMAGES_COMPOSE_FILE in run-docker-full-stack.sh, the file's own header example, and the docs (docker.mdx curl URL, coolify.mdx path + blob link, SELF_HOST.md relative link + curl URL). * fix(self-host): address review round 2 — drop unused AUTH_BEARER_TOKEN, pull-by-default, doc fixes - Remove AUTH_BEARER_TOKEN everywhere (unused in app code; PRIVATE_SERVER_KEY is the real dashboard<->api key). Simplifies run-docker-full-stack.sh secret generation. - run-docker-full-stack.sh now defaults to PULLING pre-built images; add --build for source. - docs: recommend MinIO, 'a way' (not 'recommended way') to self-host, remove Direct API Access section, trim Common Commands, SMTP vars per-line, fix PRIVATE_SERVER_KEY wording. * feat(db): versioned migrations for self-host with auto-baseline - Regenerate a single clean baseline migration; db:setup now runs drizzle-kit migrate (not push). - Add db:generate / db:migrate / db:baseline scripts. - baselineMigrationsIfNeeded(): adopt an existing (push/supabase) schema into drizzle's journal so migrate won't try to recreate existing objects ("type/table already exists"). No-ops on fresh and already-tracked databases; runs automatically in db:setup before migrate. * ci(openapi): drop stale ci.yml reference in turbo-cache comment ci.yml was removed earlier in this review; the openapi workflow's turbo cache still warms from its own prior runs via restore-keys, so this is a comment-only fix. * fix(db): make baseline squash-safe for migrate-tracked databases baselineMigrationsIfNeeded returned early whenever drizzle.__drizzle_migrations had any rows. After a migration squash, an instance tracked under the old journal (pre-squash hashes) would then let `drizzle-kit migrate` re-run the consolidated baseline — whose journal `when` is newer than every old created_at — and crash on `CREATE TYPE/TABLE "..." already exists` at startup. Now, when history exists but the consolidated baseline hash is absent, insert that hash (marking it already applied) so migrate skips it. Only the baseline entry (idx 0) is reconciled, so genuinely pending future migrations still run. Verified end-to-end against Postgres: reproduces the original crash, confirms the fix no-ops migrate on a pre-squash DB, and confirms a later 0001 migration still applies. Fresh-DB and already-adopted paths unchanged. * fix(docker): add explicit id to pnpm-store cache mounts Railway's Metal builder rejects BuildKit cache mounts that omit an explicit id (it does not default id to target like standard Docker/ BuildKit do), failing the build at parse time with: dockerfile invalid: flag '--mount=type=cache,target=/pnpm/store' is missing an id argument Add id=pnpm-store to all six cache mounts across the api, dashboard, and jobs images. Harmless on Docker Desktop / CI / GitHub Actions, and required for the Railway self-host build path. * fix(docker): drop BuildKit cache mounts for Railway compatibility Railway's builder rejects cache-mount ids not prefixed with its per-service cacheKey, which can't be hardcoded in a Dockerfile shared across CI, local Docker, and three Railway services. Remove the --mount=type=cache flag (keep --store-dir /pnpm/store); the store is baked into the fetch layer and reused by the offline install, so behaviour is unchanged. Supersedes 9c4633f2c. * fix(docker): force LF for shell scripts and Dockerfiles via .gitattributes cio-api crash-looped on Railway with 'exec /app/entrypoint.sh: No such file or directory' because the entrypoint shipped with CRLF endings (Windows checkout, core.autocrlf=true) — the '#!/bin/sh\r' shebang is unresolvable in the container. Add .gitattributes pinning *.sh and Dockerfile* to LF so container-executed files are never CRLF regardless of contributor OS. * fix(db): serialize db-setup with an advisory lock to prevent baseline double-insert Concurrent db:setup runs (e.g. multiple API replicas booting together) could both pass the baseline 'already present?' check and insert duplicate rows into drizzle.__drizzle_migrations, which has no unique constraint on hash. Wrap baseline + migrate in a pg_advisory_lock held on a dedicated connection so concurrent startups serialize: the loser blocks, then sees the baseline applied and no-ops. * docs(railway): update template deploy URL to classroomio-1 --------- Co-authored-by: Rotimi Best <best@classroomio.com> |