* 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>
---------
* fix(dashboard): API page snippet highlighting and breadcrumb i18n
Stop double-translating nav titles in breadcrumbs (t was called on
already-localized strings like Audience and API).
Use Code.Root default typescript highlighting for all API quick-start
snippets instead of unsupported bash/python/go langs.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* revert(dashboard): drop breadcrumb i18n change from API page PR
Translation warnings were unrelated; keep only the Shiki lang fix.
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(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>
* 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>
- 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
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.
* 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
---------
- 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
* feat(lms): add student certificates page and sidebar nav
Add a Certificates item to the student LMS sidebar that lists courses
where the student has earned a certificate. Each course card includes a
View Certificate action linking to the existing per-course certificate
page.
* refactor: reuse isOrgOnFreePlan helper from org store
* chore(i18n): add certificates translations for all locales
---------
exercise.svelte was auto-adding a QUESTION_TEMPLATE to the store whenever
questions were empty, which raced with hydrateExercisePageData.
applyMySubmission fired with the blank template in place, set
isFinished=true, and the guard prevented re-application once real
questions loaded.
Two fixes: guard the auto-add effect with isOrgStudent (students never
need to add questions), and gate applyMySubmission on isExerciseLoaded
so it always waits for hydrateExercisePageData to populate real questions
first.
Also fix off-by-1 in applyMySubmission (questions.length → questions.length + 1)
so currentQuestion is undefined at end-of-exercise, satisfying the
isFinished derived. Clear questionnaire.questions in onDestroy to prevent
stale questions flashing on the next exercise.
Fix lesson delete not updating sidebar/content list by filtering the
removed lesson from sections on success.
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.
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.
* 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>
Disabled items were overriding props.class with a custom class string,
stripping the Sidebar.MenuButton's height, padding, and color styles.
Drop the inline class and rely on the existing aria-disabled CSS rules.
* fix(dashboard): course menu icons, delete scroll, and settings tags auth
Remove inconsistent dropdown icons, scroll to settings sections for delete
and related actions when already on the settings page, and skip org tag
group fetches for non-admin users to avoid ORG_TEAM_NOT_AUTHORIZED errors.
* fix(api): allow org members to fetch organization tags
Use orgMemberMiddleware for GET /organization/tags instead of restricting
reads to admins. Revert the dashboard settings workaround that skipped tag
group fetches for non-admin users.
---------
Add View course site and Publish course options to org course context menus.
Replace the course header View as student button with View Course Site and
a companion overflow menu that includes View as student plus list actions.
Show a publish prompt modal for unpublished courses and support #publish
deep links in course settings.
* fix(analytics): show per-student last seen from login and session activity
Return ISO last-seen timestamps from course analytics instead of
pre-formatted strings so the dashboard can format them per student.
Batch-fetch last seen for all students and include session activity
alongside login events for more accurate, distinct values.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(analytics): destructure first row from getLastLogin query results
Promise.all returns an array per Drizzle select; nested destructuring
is required so loggedInAt and updatedAt are read from row objects.
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>
Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.
LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.
Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
* fix(dashboard): redirect students on app domain to org LMS
When a student logs in on app.classroomio.com, send them to their org
tenant domain with a full page reload instead of staying on /lms at the
app host. Org sites and self-hosted installs keep the existing behavior.
Students who explicitly open /onboarding while already belonging to an
org are no longer redirected away, so they can still create their own
academy with the same email.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* refactor(dashboard): use getOrgPublicUrl instead of duplicate helper
Main added getOrgPublicOrigin for tenant URLs; extend it with
getOrgPublicUrl for path-based redirects and drop the parallel
getOrgSiteUrl helper from org utils.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
---------
* fix(dashboard): use verified custom domain for view-as-student handoff
Prefer the org's verified custom domain over window.location.origin when
resolving the public tenant host. This prevents admin URLs like
app.example.com from being used for the student login-link handoff when
the org's public site is example.com.
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
* fix(dashboard): keep self-hosted org origin on window.location
Self-hosted deployments serve admin and LMS from the same URL, so
getOrgPublicOrigin should always return window.location.origin there
rather than preferring a configured custom domain.
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>
The course cover Upload tab stored the raw file and relied on CSS
object-cover to fake the 280x200 frame at display time. Route uploads
through the existing ImageCropper at a 280/200 aspect ratio so the saved
asset matches the frame. Also teach the cropper UploadTrigger to accept
drag-and-drop so the prior drop-to-upload UX is preserved.
A subdomain like xxazzz.myclassroomio.com set isOrgSite=true with a null
org, and the course page fell back to resolving the org from the course
itself — so courses rendered under subdomains that don't exist. Guard
the (org-site) layout so an unresolved org site 404s instead.
The preview forced images into a 280x200 box with default object-fit,
squeezing non-matching aspect ratios. Use object-cover so the image
fills the frame at its true aspect ratio and crops overflow instead.
* chore: update README with environment setup instructions and demo account credentials
fix: replace rm -rf with rimraf in build scripts for cross-platform compatibility
fix: add shell option to pnpm command in db-setup script for Windows compatibility
chore: update package.json files to use rimraf instead of rm -rf for consistency
chore: add rimraf dependency to pnpm-lock.yaml
feat: add postinstall script to install lefthook git hooks in a cross-platform manner
* docs(qa): add QA test plan, flow checklists, and bug-report workflow
Add a repo-native QA execution layer on top of FEATURE_AUDIT.md (the
feature map). Test cases live in the repo; bugs live in GitHub Issues, so
there is no duplicate tracker to keep in sync.
QA system:
- FEATURE_AUDIT.md: the feature map - every feature, its user flow, and
edge cases with file:line citations.
- qa/README.md: the workflow, severity/difficulty rubrics, label setup,
and a static feature -> flow coverage map proving all 42 audited
features have a test plan.
- qa/flows/01..13: per-flow happy-path checklists covering signup &
onboarding, course authoring, people/invites/roles, student ->
certificate, AI assistant & tutor, programs/attendance, community,
org public site, media & import, org settings, public API/MCP,
enterprise SSO/licensing, and billing & AI credits.
- .github/ISSUE_TEMPLATE/qa-bug.yml: a QA Bug template requiring
severity, difficulty, area, repro steps, and a screen recording.
Dev tooling:
- package.json: add db:studio and docker:build scripts.
- packages/db: add db:studio (drizzle-kit studio); fix db:reset on
Windows by spawning the pnpm.cmd shim with shell:true (Node's
CVE-2024-27980 fix blocks spawning .cmd/.bat without a shell).
* chore: update QA bug template and README for clarity and improved workflow
* docs: enhance DEV_SETUP_NOTES and README for clarity on prerequisites and email setup
chore: update package.json scripts for cross-platform compatibility
feat: add cross-platform Prettier check script for pre-commit hook
* Remove QA documentation and flow files for ClassroomIO
- Deleted the entire QA README.md file which contained the QA process, bug filing guidelines, and feature coverage map.
- Removed all flow files (01 to 13) that detailed the QA testing processes for various features including admin signup, course authoring, people management, student learning, AI assistant, programs, community, org public site, media management, org settings, public API, enterprise SSO, billing, and AI credits.
* fix: update postinstall script
The rigid 7-track grid always rendered in one row, so when the list was
squeezed the tag track shrank below the badge widths and tags overflowed
into the lessons/exercises column. Make the ResourceListRow.Group a
container query and stack each row's grid into a single column below
48rem, passing the column template via a CSS variable so the responsive
override applies. Also cap the row at 3 tags with a +N pill.
A student opening /org/[slug] on self-hosted stayed on the admin
dashboard. The existing routeUserToNextPage redirect is gated by
shouldRedirectOnAuth (login/signup/onboarding only) and runs once at app
init, so it never caught this route. Add a reactive guard in the admin
org layout that sends students to /lms, gated on
appInitApi.isInitializedAndReady so the role is actually known before
deciding (the student flag defaults to false while loading).
The auth button derived from stores that hydrate in stages, so it
flashed through Login -> Dashboard -> Go to LMS as isLoggedIn and the
role/basePath settled at different times. Gate the label on
appInitApi.isInitializedAndReady so it only renders once the account
(and thus role) has loaded, and show a spinner in the button while a
logged-in user's experience resolves. Add separate loading and disabled
flags to authAction and wire them through all theme navs.
Loosen the custom-domain regex to accept apex domains (1+ labels) in
addition to subdomains; the TLD already allowed any letters. Suppress
the CNAME record for apex hostnames since a CNAME is invalid at the zone
apex, and surface only the A record there. Drop the frontend apex
rejection and the misleading ".com" input suffix, and render the
domain-input prefix/suffix addons only when set.
Serve widgets from short public URLs via tenant-router with R2-backed
assets and runtime API_URL injection, so copy-paste snippets hide infra
paths. Update widget payloads, CSP, upload purge URLs, and dev-only API
inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check
YAML syntax.
The /org/[slug] routes loaded 250+ JS chunks and, after a deploy, every
chunk missed Cloudflare's edge cache and stampeded the single origin (up
to 48s to serve one static file) — the "keeps loading" lockout.
- tenant-router: opt /_app/immutable/* into the edge cache via
cf.cacheEverything; setting Cache-Control alone never populated it
- course/api: mark the 14 API singletons /* @__PURE__ */ so the barrel
tree-shakes instead of dragging every API into each importer
- courses page: import directly instead of via the feature barrels
Cuts the courses route from 257 to 225 static chunks and keeps static
assets off the origin after the first edge fill.
Sync modal editor state from the updated feed on open, clear editFeed
on reset, and remount TextEditor so a second edit shows the latest HTML
instead of stale content.