* Udated READMe for jobs env
Added instructions to clone the .env.example in the jobs directory to your .env
* corrections to greptile comment
* Greptile code review
---------
Co-authored-by: Rotimi Best <best@classroomio.com>
* fix(dashboard): prevent ERR_CONTENT_DECODING_FAILED on API proxy
Node fetch decompresses gzip/br upstream bodies while leaving
Content-Encoding set, so browsers fail auth/proxy responses with
net::ERR_CONTENT_DECODING_FAILED. Request identity encoding and strip
encoding headers from proxied responses. Also allow data: fonts in CSP
and skip UserJot on self-hosted.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): preserve Set-Cookie and use Jest for proxy tests
Copy Set-Cookie via getSetCookie/append so multi-cookie auth responses
are not collapsed on Expires commas. Convert proxy tests to Jest and
add CSP domain normalization coverage.
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>
* 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>
* 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>
* fix(i18n): translate untranslated English strings in all locale files
Audited all 9 non-English dashboard locale files against en.json and
found 495 unique keys (2,422 total entries) still containing English
text. Manually translated all user-facing strings across da, de, es,
fr, hi, pl, pt, ru, and vi while preserving ICU plural syntax,
placeholders, and brand names.
Remaining identical strings are intentional: international brand names
(Okta, Instagram), technical acronyms (SSO, API, cURL), URLs,
placeholders, and words that are spelled identically in the target
language (e.g. German Status, French Navigation).
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(i18n): translate exercise plural labels in kpi_lessons_exercises
Address Greptile review: ICU plural blocks still contained English
'exercise' / 'exercises' across all nine non-English locale files.
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>
* 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>
---------
* 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>
Lock core product decisions: attachment-level binding model with
form_attachment table, create-in-context authoring, purchase-request
pipeline replacing the hardcoded payment modal, per-lesson pulse
feedback, landing-page form and reviews blocks, admin/teacher-only
form creation, and free-standalone/paid-integrations commercial model.
Defer respondent save-and-resume, edit-submitted-response, and
one-question-per-page to phase 2. Add FORM-F1 to FORM-F9 tickets.
* 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>
* fix(dashboard): repair lesson nav build and improve locked feedback
Replace stale $isOrgStudent reference with isCourseLearnerView to fix
the Svelte build error. Disable prev/next when the adjacent item is
locked, show lock tooltips, and surface a snackbar if navigation is
blocked (including keyboard shortcuts).
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* ci: add package build check and AGENTS.md verification steps
Document required dashboard/API build commands for agents and add a
GitHub Actions workflow that runs those builds on PRs touching apps or
packages. Copy dashboard .env.example in CI so vite build has required
public env vars.
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>
Lock checks gated on isOrgStudent missed org-site students where
isStudentExperience is true but org role may be unset. Introduce
isCourseLearnerView (student experience OR org student) and mirror
the exercise page branch structure so locked lessons always show
StudentContentLockedNotice instead of the teacher empty state.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* feat(dashboard): show course progress in student sidebar
Adds a progress ring with completed-lesson count to the course sidebar
header, per-section completion percentages in the content tree, and a
"Your progress" footer card with an overall progress bar plus a
lessons/exercises breakdown. All indicators are student-only and derive
from the existing per-item isComplete flags, so they update live when a
lesson is marked complete.
* feat(dashboard): show course progress ring in header on mobile
Extracts the sidebar footer progress card into a reusable
course-progress-card component and adds a course-progress-popover
(progress ring trigger, mirroring the org nav setup ring) to the course
header beside the Assistant button. Shown to students on mobile only,
where the sidebar footer card is hidden; tapping the ring opens a
popover with the linear progress bar and lessons/exercises breakdown.
* fix(dashboard): align progress ring subtitle with ring scope
The header ring percent covers lessons and exercises while the subtitle
only counted lessons, which contradicted each other on courses with
exercises. The subtitle now uses the same all-content counts. Also
drops a duplicate useSidebar import in favor of the namespace export.
* 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>
* fix(dashboard): show locked notice instead of teacher empty state on lessons
Students viewing a locked or progression-blocked lesson were falling
through to the teacher empty state with Get Started. Align lesson
access checks with the shared getStudentContentLockReason helper used
by exercises and the course layout, treat missing course content as
inaccessible for students, and use org student role for sidebar lock
blocking on the app host.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): only show lesson lock notice when lock reason is known
Address Greptile review: avoid defaulting to teacher_locked when the
lesson is missing from the content tree, and wait for course/lesson
state before dismissing the student loading spinner.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
---------
]Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* 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
---------
* 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>
* feat(dashboard): add content page options menu
Add a three-dot menu on the course Content page with actions to unlock
all locked lessons/exercises and toggle content grouping. Reuse shared
locked-content collection logic from course settings.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): keep unlock-all enabled and add menu icons
Show a success snackbar when all content is already unlocked instead of
disabling the action. Add lock-open and table-of-contents icons to menu items.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): check courseApi.update return for grouping toggle
Use the update() return value instead of courseApi.success, which can be
false after a successful PUT if the follow-up refresh request fails.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
---------
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* 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>
* feat(dashboard): add lesson transcript and summarize actions in view mode
Add Show transcript and Summarize lesson outline buttons beneath lesson
videos in view mode. Show transcript opens the existing transcript side
panel when an uploaded video has a transcript. Summarize lesson appears
on every lesson and sends a contextual prompt to the AI assistant.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): localize summarize lesson AI prompts
Move hardcoded summarize prompt strings into translation keys so the
prompt sent to the AI assistant follows the user's app language.
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>
* feat(website): update AI builds section headline and description
Replace 'Your AI Sidekick In Every Course' with positioning focused on
turning company knowledge into courses, matching marketing copy patterns
used elsewhere on the site.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(website): remove em dash from AI builds section description
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(website): remove em dash from chat with builder lane copy
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* feat(website): update AI builds headline to business outcome positioning
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(website): accent business outcome in AI builds headline
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>
* 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>
---------
Converted 19 screenshots from PNG to WebP with quality 80, width capped at 1280px.
File sizes reduced from 1.3-1.5 MB each down to 12-25 KB.
Updated all image references in railway.mdx and coolify.mdx accordingly.
Replace break-all on chat message prose with break-words so normal text
wraps at word boundaries. Apply overflow-wrap only to inline code and
links, and use horizontal scroll for fenced code blocks.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): fix instructor avatar upload on course landing page
The crop dialog was hidden behind the full-screen (z-250) landing-page
editor because bits-ui portals it to <body> at z-modal (200), and every
crop triggered an unbounded loop of POST /media/image requests.
- Add opt-in `elevatedDialog` prop to UploadImage that raises the crop
dialog above the editor takeover; enable it in the instructor,
certificate, and review landing-page editor forms.
- Break the upload effect loop in instructor-form by reading `course.id`
through `untrack`, so the effect no longer re-runs when the setter
reassigns `course` after each unique upload URL.
- Handle upload failures in instructor-form with a snackbar and always
reset `isUploading`, so a failed upload no longer leaves the widget
stuck and silent.
* docs(email): add email notification preferences PRD
Specify a Settings feature letting users enable/disable notification emails at two scopes: personal per-user preferences and org-wide admin defaults. Both gates must allow a send; transactional emails stay mandatory. Grounds storage in organization.settings and a new profile.settings jsonb, with a single preference guard at the email enqueue chokepoint.
Logged-in users no longer lose the Join Course button while session
checks, app init, or auto-enrollment run. The button stays visible and
shows a loading spinner instead of being hidden during those flows.
* 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>
Wrap list-view course rows in anchor tags so users can open courses in
new tabs via right-click or modifier-click, matching the grid card layout.
Falls back to button behavior when a custom explore click handler is set.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): respect defaultMode light and track=false for color mode
ModeWatcher defaults userPrefersMode to system and enables OS tracking
before components mount, so prefers-color-scheme won on first paint even
with defaultMode="light" and track={false}.
- Add a blocking init script in app.html that defaults empty storage to
light before any JS bundle runs
- Disable ModeWatcher head injection to avoid duplicate init scripts
- Keep track={false} on ModeWatcher and sync system tracking only when
userPrefersMode is explicitly system
- Update the sidebar theme toggle to enable OS tracking only when the
user selects System
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): ignore stale system color mode until user opts in
mode-watcher leaves mode-watcher-mode as system for anyone who never
picked a theme. ModeWatcher onMount then calls setMode('system'), which
overrides defaultMode="light" and track={false}.
Track an explicit sidebar choice separately and normalize stale system
values to light during the app.html init script and on mount.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): bootstrap color mode before mode-watcher module init
Even with cleared localStorage, mode-watcher's module-level derivedMode
could apply the OS color-scheme before ModeWatcher mounted. Stop relying
on ModeWatcher for document classes and bootstrap light mode from
hooks.client.ts immediately after the app.html script runs.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* refactor(dashboard): collapse color-mode workaround onto mode-watcher
The scaffolding (color-mode.svelte.ts state module, color-mode-document.ts,
init-color-mode.client.ts) existed only to work around disableHeadScriptInjection,
which had turned off mode-watcher's own setInitialMode script — the very script
that seeds localStorage before the module's eager "system" write and prevents FOUC.
Let ModeWatcher inject that script again (defaultMode="light") and drive theme
application, tracking, and Sonner theming. Keep only the app.html bootstrap for
the one thing mode-watcher can't do: treat a returning user's stale "system" as
light unless they explicitly chose it. The theme toggle now calls mode-watcher's
setMode directly and marks the choice explicit.
* fix(dashboard): run color-mode bootstrap as external script to satisfy CSP
The inline app.html bootstrap and mode-watcher's own setInitialMode script were
both blocked by the nonce-based CSP (script-src has no 'unsafe-inline'), so
localStorage was never seeded before mode-watcher's eager "system" write — every
fresh load followed the OS and came up dark.
Move the seed + FOUC logic to a same-origin static file (static/theme-init.js),
which 'self' allows without a nonce, and load it as a parser-blocking script in
app.html so it still runs before the app bundle. Disable ModeWatcher's inline
head-script injection since it is CSP-blocked and now redundant, and paint the
root element with var(--background) so the pre-hydration shell never flashes
white in dark mode.
Verified with Playwright across fresh/explicit/stale-system cases in light and
dark OS settings; no theme-related CSP violations remain.
---------
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): redirect to course start after auto-enroll
After successful enrollment on the org-site enroll page, verified users
were always sent to /lms because resolvePostEnrollRedirect was hardcoded.
Restore the API redirectTo target (/courses/:id/lessons?next=true) so
students land on their first lesson, with /course/:slug as fallback.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): match LMS continue path after auto-enroll
Use the same student course continue URL as the LMS home page
(/courses/:id/lessons?next=true) via a shared helper, and navigate with
goto instead of a hard reload or org-site /course/:slug fallback.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
---------
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* 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}
createInitialModeExpression returns bare JS, not a script tag. Injecting it
via {@html} in svelte:head printed setInitialMode source on customer org
sites. Let ModeWatcher inject the script correctly instead.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* 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>
* 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>