From d5b07d356e09450b0163edfebca32f97f4f2caa1 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 7 Jun 2026 18:43:05 +0100 Subject: [PATCH] feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663) New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it --- .env.example | 26 +- PRD-TRACKER.md | 2 + README.md | 51 +- apps/api/.env.example | 8 + apps/api/README.md | 80 ++ apps/api/package.json | 4 +- .../__tests__/agent-lesson-content.test.ts | 2 +- .../src/__tests__/ai-credits-usage.test.ts | 2 +- .../balance-answer-positions.test.ts | 122 ++ .../course-go-live-readiness.test.ts | 15 +- .../api/src/__tests__/question-update.test.ts | 2 +- apps/api/src/app.ts | 12 +- apps/api/src/config/dashboard-url.ts | 20 - apps/api/src/constants/index.ts | 9 +- apps/api/src/index.ts | 4 +- apps/api/src/middlewares/api-key.ts | 2 +- apps/api/src/middlewares/course-member.ts | 2 +- apps/api/src/middlewares/license.ts | 2 +- .../src/middlewares/org-admin-or-api-key.ts | 2 +- apps/api/src/middlewares/rate-limiter.ts | 4 +- apps/api/src/middlewares/signup-guard.ts | 2 +- apps/api/src/routes/account/account.ts | 21 +- apps/api/src/routes/admin/queues.ts | 32 +- apps/api/src/routes/agent/agent.ts | 32 +- apps/api/src/routes/course/content.ts | 2 +- apps/api/src/routes/course/course.ts | 4 +- apps/api/src/routes/course/exercise.ts | 2 +- apps/api/src/routes/course/lesson-language.ts | 2 +- apps/api/src/routes/course/people.ts | 2 +- apps/api/src/routes/course/presign.ts | 4 +- apps/api/src/routes/course/section.ts | 2 +- apps/api/src/routes/hls/hls.ts | 172 +++ apps/api/src/routes/hls/index.ts | 1 + apps/api/src/routes/invite/invite.ts | 66 + apps/api/src/routes/org-site/public-course.ts | 33 +- apps/api/src/routes/organization/assets.ts | 232 +++- .../src/routes/organization/organization.ts | 61 +- apps/api/src/routes/transcripts/index.ts | 1 + .../api/src/routes/transcripts/transcripts.ts | 101 ++ apps/api/src/routes/unsplash/unsplash.ts | 2 +- apps/api/src/services/account/index.ts | 1 + apps/api/src/services/account/profile.ts | 8 +- .../src/services/account/view-as-student.ts | 24 + apps/api/src/services/agent/chat-history.ts | 8 +- apps/api/src/services/agent/compact.ts | 2 +- apps/api/src/services/agent/run-state.ts | 123 +- apps/api/src/services/agent/student-tools.ts | 10 +- apps/api/src/services/agent/tutor-usage.ts | 2 +- apps/api/src/services/analytics/reads.ts | 4 +- apps/api/src/services/assets/assets.ts | 539 -------- apps/api/src/services/assets/index.ts | 2 +- .../services/course-import/course-import.ts | 20 +- apps/api/src/services/course/invite.ts | 59 +- apps/api/src/services/course/people.ts | 14 +- apps/api/src/services/course/public-course.ts | 55 +- apps/api/src/services/dash.ts | 4 +- apps/api/src/services/exercise/index.ts | 2 +- .../src/services/exercise/video-recording.ts | 4 +- apps/api/src/services/index.ts | 2 +- apps/api/src/services/jobs/email-jobs.ts | 2 +- apps/api/src/services/jobs/media-jobs.ts | 10 +- apps/api/src/services/lesson/index.ts | 2 +- apps/api/src/services/license.ts | 2 +- apps/api/src/services/media.ts | 6 +- apps/api/src/services/newsfeed/newsfeed.ts | 9 +- apps/api/src/services/onboarding.ts | 2 +- apps/api/src/services/org/domain.ts | 2 +- apps/api/src/services/organization.ts | 36 +- .../api/src/services/organization/audience.ts | 8 +- apps/api/src/services/organization/invite.ts | 157 ++- apps/api/src/services/program/goal.ts | 8 +- .../api/src/services/submission/submission.ts | 4 +- apps/api/src/services/tag.ts | 14 +- apps/api/src/services/v1/course.ts | 2 +- apps/api/src/services/widget-payload.ts | 2 +- apps/api/src/utils/course-completion.ts | 8 +- apps/api/src/utils/errors.ts | 27 +- apps/api/src/utils/openapi/index.ts | 2 +- apps/api/src/utils/redis/limiter.ts | 2 +- apps/api/src/utils/redis/oauth-handoff.ts | 2 +- apps/api/tsconfig.json | 1 + apps/dashboard/package.json | 2 + apps/dashboard/src/app.html | 52 - apps/dashboard/src/hooks.server.ts | 2 + .../features/account/api/account.svelte.ts | 17 +- .../src/lib/features/account/utils/types.ts | 1 + .../lib/features/ai-assistant/CHAT_VS_RUN.md | 91 ++ .../ai-assistant/ai-course-chat.svelte | 620 ++++++++- .../ai-assistant/api/ai-assistant.svelte.ts | 143 +++ .../ai-assistant/chat-changes-card.svelte | 90 ++ .../ai-assistant/chat-message-list.svelte | 21 +- .../ai-assistant/content-ask-ai-bar.svelte | 165 +++ .../src/lib/features/ai-assistant/index.ts | 2 + .../ai-assistant/message-bubble.svelte | 13 +- .../features/ai-assistant/plan-view.svelte | 359 +++--- .../ai-assistant/progress-card.svelte | 166 ++- .../ai-assistant/run-monitor-pane.svelte | 185 +++ .../ai-assistant/utils/content-ask-ai-bar.ts | 43 + .../ai-assistant/utils/run-summary.ts | 312 +++++ .../lib/features/ai-assistant/utils/store.ts | 12 + .../lib/features/ai-assistant/utils/types.ts | 64 + .../analytics/components/trend-chart.svelte | 18 +- .../src/lib/features/app/init.svelte.ts | 4 +- .../src/lib/features/app/layout-setup.ts | 55 +- .../lib/features/course/api/lesson.svelte.ts | 6 +- .../analytics/analytics-graph.svelte | 38 +- .../course/components/course-header.svelte | 74 +- .../components/exercise/edit-mode.svelte | 1 - .../exercise/question-type-select.svelte | 111 +- .../components/lesson/video/hls-encoder.ts | 830 ++++++++++++ .../lesson/video/lesson-video-bus.svelte.ts | 5 + .../lesson/video/lesson-video-player.svelte | 299 ++++- .../video/lesson-video-simple-card.svelte | 3 +- .../lesson/video/transcript-side-panel.svelte | 99 +- .../lesson/video/upload-video.svelte | 95 +- .../lesson/video/video-card-dropdown.svelte | 165 ++- .../lesson/video/video-card-utils.ts | 62 +- .../lesson/video/video-thumbnail-utils.ts | 73 +- .../components/lesson/video/video.svelte | 10 +- .../components/sidebar/course-sidebar.svelte | 6 +- .../components/view-as-student-modal.svelte | 67 + .../lib/features/course/pages/exercise.svelte | 152 ++- .../lib/features/course/pages/lesson.svelte | 20 +- .../lib/features/course/pages/settings.svelte | 4 +- .../features/course/utils/course-preview.ts | 45 + .../lib/features/course/utils/marks-utils.ts | 11 +- .../course/utils/public-course-mappers.ts | 4 +- .../src/lib/features/course/utils/types.ts | 2 + .../features/license/api/license.svelte.ts | 18 +- .../lib/features/lms/pages/dashboard.svelte | 71 +- .../media/api/media-manager.svelte.ts | 44 +- .../manage-thumbnails-dialog.svelte | 256 ++-- .../src/lib/features/media/utils/types.ts | 6 + .../src/lib/features/org/api/org.svelte.ts | 44 +- .../components/login-activity-chart.svelte | 18 +- .../org/utils/landing-page-components.ts | 23 + .../lib/features/org/utils/landing-page.ts | 182 +-- .../src/lib/features/org/utils/types.ts | 8 + .../components/ai-credits/usage-chart.svelte | 18 +- .../settings/components/auth-sso.svelte | 4 +- .../components/theme-preview-dialog.svelte | 53 +- .../settings/pages/landingpage.svelte | 22 +- .../lib/features/settings/pages/teams.svelte | 92 ++ .../components/editor/editor.svelte | 135 +- .../components/editor/header-form.svelte | 28 +- .../components/editor/instructor-form.svelte | 4 +- .../editor/skills-tools-form.svelte | 155 +++ .../course-landing-page.svelte | 587 +-------- .../features/ui/course-landing-page/utils.ts | 138 ++ .../ui/media-player/media-player.svelte | 3 +- .../media-player/players/plyr-player.svelte | 78 +- .../src/lib/features/ui/media-player/types.ts | 5 + .../src/lib/features/ui/powered-by.svelte | 3 +- .../features/ui/role-based-security.svelte | 16 +- .../features/ui/sidebar/footer/menu.svelte | 47 +- .../src/lib/features/ui/text-editor.svelte | 55 +- .../ui/upload-widget/upload-widget.svelte | 14 +- .../features/widget/utils/layout-thumbnail.ts | 4 +- apps/dashboard/src/lib/utils/csp-domains.js | 3 +- .../src/lib/utils/functions/appSetup.ts | 16 +- .../src/lib/utils/functions/logout.ts | 2 + .../src/lib/utils/functions/metaTags.ts | 10 +- .../src/lib/utils/proxy-api-request.ts | 4 +- .../src/lib/utils/services/api/index.ts | 15 +- .../src/lib/utils/services/auth/client.ts | 11 +- .../src/lib/utils/services/auth/session.ts | 2 + .../src/lib/utils/services/posthog/index.ts | 5 +- .../src/lib/utils/services/userjot/index.ts | 22 +- .../src/lib/utils/services/userjot/types.d.ts | 5 +- apps/dashboard/src/lib/utils/store/app.ts | 1 + .../src/lib/utils/translations/da.json | 87 +- .../src/lib/utils/translations/de.json | 87 +- .../src/lib/utils/translations/en.json | 84 +- .../src/lib/utils/translations/es.json | 89 +- .../src/lib/utils/translations/fr.json | 87 +- .../src/lib/utils/translations/hi.json | 89 +- .../src/lib/utils/translations/pl.json | 85 +- .../src/lib/utils/translations/pt.json | 87 +- .../src/lib/utils/translations/ru.json | 91 +- .../src/lib/utils/translations/vi.json | 87 +- .../routes/(app)/courses/[id]/+layout.svelte | 35 +- .../courses/[id]/landingpage/+page.svelte | 43 +- .../(app)/courses/[id]/marks/+page.svelte | 4 +- .../src/routes/(auth)/signup/+page.svelte | 6 +- .../src/routes/(org-site)/+layout.svelte | 7 +- .../(org-site)/course/[slug]/+page.server.ts | 9 +- .../(org-site)/course/[slug]/+page.svelte | 85 +- .../routes/(org-site)/course/[slug]/+page.ts | 15 + .../course/[slug]/enroll/+page.svelte | 4 - .../[slug]/lesson/[itemSlug]/+page.svelte | 28 + .../routes/(org-site)/courses/+page.server.ts | 15 +- .../routes/(org-site)/courses/+page.svelte | 206 +-- .../src/routes/(org-site)/courses/+page.ts | 16 + apps/dashboard/src/routes/+layout.svelte | 5 - apps/dashboard/src/routes/+page.svelte | 28 +- apps/dashboard/src/routes/+page.ts | 18 + .../invite/link/[hash]/+layout.server.ts | 34 + .../routes/invite/link/[hash]/+page.svelte | 119 ++ apps/dashboard/svelte.config.js | 18 +- apps/dashboard/vite.config.ts | 11 +- apps/dashboard/wrangler.jsonc | 2 +- apps/docs/app/root.tsx | 38 +- apps/docs/content/docs/home.mdx | 6 +- .../content/docs/quickstart/self-hosting.mdx | 34 +- .../src/widgets/course-widget/bootstrap.ts | 52 - apps/jobs/README.md | 11 +- apps/jobs/package.json | 4 + apps/jobs/src/bootstrap.js | 21 - apps/jobs/src/config/env.js | 0 apps/jobs/src/config/env.ts | 3 +- apps/jobs/src/config/storage.js | 78 -- apps/jobs/src/index.js | 11 - apps/jobs/src/index.ts | 3 +- apps/jobs/src/processors/emails/index.js | 29 - apps/jobs/src/processors/emails/send.js | 217 ---- .../src/processors/media/extract-audio.js | 309 ----- .../processors/media/generate-thumbnail.js | 338 ----- .../processors/media/generate-thumbnail.ts | 46 +- apps/jobs/src/processors/media/index.js | 32 - .../src/processors/media/probe-metadata.js | 311 ----- .../src/processors/media/transcribe-audio.js | 538 -------- .../jobs/src/services/transcription/openai.js | 245 ---- apps/jobs/src/services/transcription/vtt.js | 37 - apps/jobs/src/services/transcription/vtt.ts | 42 +- apps/jobs/src/utils/cancel.js | 214 --- apps/jobs/src/utils/ffmpeg.js | 250 ---- apps/jobs/src/utils/ffmpeg.ts | 29 + apps/jobs/src/utils/logger.js | 39 - apps/jobs/src/utils/storage.js | 266 ---- .../src/workers/agent-course-generation.ts | 82 ++ apps/jobs/src/workers/emails.js | 232 ---- apps/jobs/src/workers/maintenance.js | 203 --- apps/jobs/src/workers/maintenance.ts | 19 + apps/jobs/src/workers/media-transcribe.js | 265 ---- apps/jobs/src/workers/media.js | 265 ---- apps/jobs/tsconfig.json | 1 + apps/tenant-router/README.md | 18 +- apps/tenant-router/package.json | 7 +- apps/tenant-router/src/index.ts | 297 ++++- apps/tenant-router/wrangler.toml | 24 + .../lib/components/ai-builds-section.svelte | 10 +- .../lib/components/ai-tutor-section.svelte | 2 +- apps/website/src/lib/components/footer.svelte | 8 +- apps/website/src/lib/components/hero.svelte | 2 +- apps/website/src/lib/components/index.ts | 1 - .../src/lib/components/more-features.svelte | 104 -- .../src/lib/components/navigation.svelte | 26 +- apps/website/src/lib/utils/userjot/index.ts | 44 + apps/website/src/lib/utils/userjot/types.d.ts | 19 + .../compliance-training/+page.svelte | 8 +- .../customer-education/+page.svelte | 6 +- .../docs-vs-company-academy/+page.svelte | 2 +- .../(marketing)/partner-training/+page.svelte | 6 +- apps/website/src/routes/+layout.svelte | 6 +- apps/website/src/routes/+layout.ts | 40 +- .../src/routes/public-courses/+page.svelte | 347 +++++ .../src/routes/roadmap/+page.server.ts | 7 + apps/website/src/routes/roadmap/+page.svelte | 107 -- apps/website/src/routes/teach/+page.svelte | 14 +- apps/website/static/ai-course-builder.jpeg | Bin 493060 -> 0 bytes .../static/beautiful-academy-site.jpeg | Bin 586996 -> 0 bytes apps/website/static/cio-hero.webp | Bin 229556 -> 0 bytes apps/website/static/classroomio-courses.png | Bin 943605 -> 0 bytes .../static/classroomio-opengraph-image.png | Bin 245199 -> 496815 bytes .../classroomio-opengraph-image_launch.png | Bin 742098 -> 0 bytes apps/website/static/claude-logo.svg | 7 - apps/website/static/codex-logo.png | Bin 354585 -> 0 bytes apps/website/static/compliance-dashboard.jpeg | Bin 533826 -> 0 bytes apps/website/static/course-compliance.jpeg | Bin 856714 -> 0 bytes apps/website/static/cursor-logo.png | Bin 29610 -> 0 bytes apps/website/static/customizable-academy.jpeg | Bin 435922 -> 0 bytes apps/website/static/discord-blue.png | Bin 10386 -> 0 bytes .../website/static/docs-vs-customer-site.jpeg | Bin 368195 -> 0 bytes apps/website/static/download-pdf.png | Bin 924547 -> 0 bytes apps/website/static/embeddable-widget.jpeg | Bin 452283 -> 0 bytes .../free-tools/pomodoro/settings-icon.svg | 3 - apps/website/static/github-mark.png | Bin 6393 -> 0 bytes apps/website/static/hero-1.svg | 87 -- apps/website/static/hero-2.svg | 19 - apps/website/static/hero-3.svg | 48 - apps/website/static/hero-4.svg | 8 - apps/website/static/hero-5.svg | 16 - apps/website/static/hero-academy.jpeg | Bin 450848 -> 0 bytes apps/website/static/hero-section.png | Bin 438015 -> 0 bytes apps/website/static/landing-page-builder.webp | Bin 77156 -> 0 bytes apps/website/static/latest-courses.jpeg | Bin 806708 -> 0 bytes apps/website/static/ng.png | Bin 600 -> 0 bytes apps/website/static/opencode-logo.svg | 1 - apps/website/static/org-compliance.jpeg | Bin 758472 -> 0 bytes apps/website/static/prime_twitter.svg | 15 - apps/website/static/public-courses.jpeg | Bin 979397 -> 0 bytes apps/website/static/roadmap-one.svg | 3 - apps/website/static/roadmap-two.svg | 3 - apps/website/static/socials-avatar.png | Bin 5536 -> 0 bytes apps/website/static/teach/get-paid.svg | 15 - .../website/static/teach/inspire-learners.svg | 6 - apps/website/static/teach/star.svg | 40 - .../static/teach/teach-from-anywhere.svg | 9 - apps/website/static/teach/teacher-banner.png | Bin 907933 -> 0 bytes apps/website/static/teach/teacher-hero.png | Bin 254669 -> 0 bytes apps/website/static/teach/thumbs-up.svg | 36 - apps/website/static/teach/user-profile.png | Bin 58055 -> 0 bytes apps/website/static/team-management.png | Bin 685107 -> 0 bytes apps/website/static/twitter.svg | 15 - apps/website/static/video-transcription.png | Bin 812843 -> 0 bytes docker/docker-compose.yaml | 4 +- docker/docs/SELF_HOST.md | 10 +- package.json | 7 +- packages/ai-assistant/src/prompt/teacher.ts | 60 +- packages/ai-assistant/src/templates/index.ts | 6 +- packages/ai-assistant/src/types.ts | 20 +- packages/core/README.md | 56 + packages/core/package.json | 61 + .../core/scripts/restore-bare-specifiers.mjs | 49 + packages/core/src/config/dashboard-url.ts | 48 + {apps/api => packages/core}/src/config/env.ts | 0 .../core}/src/config/storage.ts | 2 +- packages/core/src/index.ts | 13 + .../src/services/agent/agent-tool-schemas.ts | 29 +- .../agent/balance-answer-positions.ts | 171 +++ .../core}/src/services/agent/chat-context.ts | 6 +- .../core}/src/services/agent/chat-tools.ts | 374 +++++- .../src/services/agent/context-window.ts | 0 .../agent/course-generation-runner.ts | 399 ++++++ .../core}/src/services/agent/document.ts | 16 +- .../core}/src/services/agent/fetch-url.ts | 8 +- .../src/services/agent/lesson-content.ts | 0 .../core}/src/services/agent/model-context.ts | 4 +- .../src/services/agent/question-update.ts | 2 +- .../src/services/agent/sanitize-tool-calls.ts | 0 .../core}/src/services/agent/summarize.ts | 2 +- .../core}/src/services/agent/usage.ts | 2 +- packages/core/src/services/assets/assets.ts | 1142 +++++++++++++++++ .../core}/src/services/course/content.ts | 5 +- .../core}/src/services/course/course.ts | 4 +- .../src/services/course/go-live-readiness.ts | 7 +- .../core}/src/services/course/landing-page.ts | 22 +- .../services/course/public-course-guard.ts | 2 +- .../core}/src/services/course/section.ts | 2 +- .../core}/src/services/course/slug.ts | 2 +- packages/core/src/services/course/utils.ts | 159 +++ .../core}/src/services/exercise/exercise.ts | 8 +- packages/core/src/services/exercise/utils.ts | 455 +++++++ packages/core/src/services/jobs/media-jobs.ts | 337 +++++ .../core}/src/services/lesson-language.ts | 6 +- .../core}/src/services/lesson/lesson.ts | 8 +- .../core}/src/utils/lesson-media.ts | 16 +- .../core}/src/utils/presigned-urls.ts | 0 packages/core/src/utils/redis-keys.ts | 16 + .../core}/src/utils/redis/redis.ts | 2 +- {apps/api => packages/core}/src/utils/s3.ts | 4 +- .../core}/src/utils/sanitize-html.ts | 0 .../core}/src/utils/tinybird.ts | 0 .../api => packages/core}/src/utils/upload.ts | 0 packages/core/tsconfig.json | 34 + packages/db/src/auth.ts | 16 +- packages/db/src/auth/login-link.ts | 44 + packages/db/src/constants.ts | 27 +- packages/db/src/queries/agent/run-state.ts | 15 + packages/db/src/queries/assets/assets.ts | 125 ++ packages/db/src/queries/course/course.ts | 20 +- packages/db/src/queries/course/invite.ts | 10 +- .../db/src/queries/course/public-course.ts | 6 + .../media-transcript/media-transcript.ts | 32 + packages/db/src/queries/organization/index.ts | 1 + .../db/src/queries/organization/invite.ts | 4 +- .../src/queries/organization/link-invite.ts | 117 ++ .../src/queries/organization/organization.ts | 4 +- packages/db/src/queries/program/goal.ts | 10 +- packages/db/src/schema.ts | 8 +- .../db/src/scripts/generate-login-link.ts | 24 +- .../src/enqueue/agent-course-generation.ts | 25 + packages/jobs/src/enqueue/index.ts | 1 + .../src/payloads/agent-course-generation.ts | 15 + packages/jobs/src/payloads/index.ts | 1 + packages/jobs/src/payloads/maintenance.ts | 9 + packages/jobs/src/queues/defaults.ts | 6 + packages/jobs/src/queues/names.ts | 7 +- .../course-landing-page.stories.svelte | 89 ++ packages/ui/README.md | 16 + packages/ui/package.json | 3 +- .../ui/src/base/dialog/dialog-content.svelte | 6 +- .../ui/src/base/dialog/dialog-footer.svelte | 22 +- packages/ui/src/base/page/page.svelte | 5 +- .../ui/src/base/select/select-trigger.svelte | 4 +- .../renderers/textarea/preview.svelte | 15 +- .../renderers/textarea/take.svelte | 31 +- .../custom/media-player/media-player.svelte | 14 +- .../media-player/players/muse-player.svelte | 4 +- .../media-player/players/plyr-player.svelte | 407 +++++- packages/ui/src/custom/media-player/types.ts | 22 + .../ui/src/custom/org-landing-page/README.md | 219 ++++ .../org-landing-page/bold/course-card.svelte | 122 +- .../org-landing-page/bold/course-tokens.ts | 42 + .../org-landing-page/bold/course.svelte | 62 + .../custom/org-landing-page/bold/hero.svelte | 41 +- .../src/custom/org-landing-page/bold/index.ts | 7 + .../{bold.svelte => bold/org.svelte} | 22 +- .../src/custom/org-landing-page/bold/vars.ts | 10 + .../org-landing-page/classic/course-tokens.ts | 25 + .../org-landing-page/classic/course.svelte | 62 + .../org-landing-page/classic/hero.svelte | 39 +- .../custom/org-landing-page/classic/index.ts | 7 + .../{classic.svelte => classic/org.svelte} | 20 +- .../custom/org-landing-page/classic/vars.ts | 10 + .../corporate/course-tokens.ts | 24 + .../org-landing-page/corporate/course.svelte | 64 + .../org-landing-page/corporate/hero.svelte | 37 +- .../org-landing-page/corporate/index.ts | 8 + .../org.svelte} | 20 +- .../custom/org-landing-page/corporate/vars.ts | 11 + .../org-landing-page/course-chips.svelte | 82 ++ .../org-landing-page/course-curriculum.svelte | 159 +++ .../course-info-blocks.svelte | 214 +++ .../org-landing-page/course-instructor.svelte | 62 + .../course-landing-page.helpers.ts | 52 + .../course-landing-page.tokens.ts | 31 + .../org-landing-page/course-pricing.svelte | 89 ++ .../org-landing-page/course-reviews.svelte | 84 ++ .../course-section-nav.svelte | 71 + .../course-social-proof.svelte | 79 ++ .../org-landing-page/course-tokens-base.ts | 228 ++++ .../custom/org-landing-page/edit-context.ts | 28 +- .../editorial/course-tokens.ts | 68 + .../org-landing-page/editorial/course.svelte | 64 + .../org-landing-page/editorial/hero.svelte | 39 +- .../org-landing-page/editorial/index.ts | 7 + .../org.svelte} | 22 +- .../custom/org-landing-page/editorial/vars.ts | 36 + .../src/custom/org-landing-page/fixtures.ts | 173 ++- .../ui/src/custom/org-landing-page/index.ts | 122 +- .../landing-page-footer.tokens.ts | 2 +- .../org-landing-page/minimal/course-tokens.ts | 3 + .../org-landing-page/minimal/course.svelte | 64 + .../org-landing-page/minimal/hero.svelte | 39 +- .../custom/org-landing-page/minimal/index.ts | 7 + .../{minimal.svelte => minimal/org.svelte} | 20 +- .../custom/org-landing-page/minimal/vars.ts | 5 + .../org-landing-page/saas/course-tokens.ts | 29 + .../org-landing-page/saas/course.svelte | 64 + .../custom/org-landing-page/saas/hero.svelte | 31 +- .../src/custom/org-landing-page/saas/index.ts | 8 + .../{saas.svelte => saas/org.svelte} | 41 +- .../src/custom/org-landing-page/saas/vars.ts | 10 + .../studio/course-card.svelte | 2 +- .../org-landing-page/studio/course-tokens.ts | 26 + .../org-landing-page/studio/course.svelte | 64 + .../org-landing-page/studio/hero.svelte | 33 +- .../custom/org-landing-page/studio/index.ts | 7 + .../{studio.svelte => studio/org.svelte} | 20 +- .../custom/org-landing-page/studio/vars.ts | 8 + .../org-landing-page/tech/course-tokens.ts | 58 + .../org-landing-page/tech/course.svelte | 64 + .../custom/org-landing-page/tech/hero.svelte | 37 +- .../src/custom/org-landing-page/tech/index.ts | 8 + .../{tech.svelte => tech/org.svelte} | 20 +- .../src/custom/org-landing-page/tech/vars.ts | 13 + .../terminal/course-tokens.ts | 89 ++ .../org-landing-page/terminal/course.svelte | 64 + .../org-landing-page/terminal/hero.svelte | 37 +- .../custom/org-landing-page/terminal/index.ts | 7 + .../{terminal.svelte => terminal/org.svelte} | 22 +- .../custom/org-landing-page/terminal/vars.ts | 36 + .../custom/org-landing-page/theme-style.ts | 120 +- .../org-landing-page/theme-vars-base.ts | 70 + .../ui/src/custom/org-landing-page/types.ts | 154 +++ .../vibrant/course-card.svelte | 2 +- .../org-landing-page/vibrant/course-tokens.ts | 59 + .../org-landing-page/vibrant/course.svelte | 64 + .../org-landing-page/vibrant/hero.svelte | 37 +- .../custom/org-landing-page/vibrant/index.ts | 7 + .../{vibrant.svelte => vibrant/org.svelte} | 22 +- .../custom/org-landing-page/vibrant/vars.ts | 10 + .../custom/public-course/exercise-view.svelte | 12 +- packages/ui/src/custom/public-course/index.ts | 1 + .../custom/public-course/lesson-view.svelte | 51 +- .../public-exercise-attempts-storage.ts | 5 + packages/ui/src/custom/public-course/types.ts | 8 + .../simple-logo-nav/simple-logo-nav.svelte | 2 +- packages/ui/src/index.ts | 10 +- packages/ui/src/output.css | 2 +- packages/utils/package.json | 4 + packages/utils/src/errors/index.ts | 29 + packages/utils/src/functions/index.ts | 1 + .../utils/src/functions/transcript-vtt.ts | 45 + packages/utils/src/license/index.ts | 1 + packages/utils/src/license/plan-features.ts | 33 + .../utils/src/validation/assets/assets.ts | 64 + .../utils/src/validation/course/course.ts | 4 + packages/utils/src/validation/media/media.ts | 5 + .../validation/organization/organization.ts | 16 +- pnpm-lock.yaml | 629 ++++++--- pnpm-workspace.yaml | 3 + prd/copy-page-as-markdown/README.md | 184 +++ prd/scorm-support/README.md | 821 ++++++++++++ prd/scorm-support/implementation-plan.md | 173 +++ 496 files changed, 19050 insertions(+), 8312 deletions(-) create mode 100644 apps/api/src/__tests__/balance-answer-positions.test.ts delete mode 100644 apps/api/src/config/dashboard-url.ts create mode 100644 apps/api/src/routes/hls/hls.ts create mode 100644 apps/api/src/routes/hls/index.ts create mode 100644 apps/api/src/routes/transcripts/index.ts create mode 100644 apps/api/src/routes/transcripts/transcripts.ts create mode 100644 apps/api/src/services/account/view-as-student.ts delete mode 100644 apps/api/src/services/assets/assets.ts create mode 100644 apps/dashboard/src/lib/features/ai-assistant/CHAT_VS_RUN.md create mode 100644 apps/dashboard/src/lib/features/ai-assistant/chat-changes-card.svelte create mode 100644 apps/dashboard/src/lib/features/ai-assistant/content-ask-ai-bar.svelte create mode 100644 apps/dashboard/src/lib/features/ai-assistant/run-monitor-pane.svelte create mode 100644 apps/dashboard/src/lib/features/ai-assistant/utils/content-ask-ai-bar.ts create mode 100644 apps/dashboard/src/lib/features/ai-assistant/utils/run-summary.ts create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.ts create mode 100644 apps/dashboard/src/lib/features/course/components/view-as-student-modal.svelte create mode 100644 apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/skills-tools-form.svelte create mode 100644 apps/dashboard/src/routes/(org-site)/course/[slug]/+page.ts create mode 100644 apps/dashboard/src/routes/(org-site)/courses/+page.ts create mode 100644 apps/dashboard/src/routes/+page.ts create mode 100644 apps/dashboard/src/routes/invite/link/[hash]/+layout.server.ts create mode 100644 apps/dashboard/src/routes/invite/link/[hash]/+page.svelte delete mode 100644 apps/jobs/src/bootstrap.js delete mode 100644 apps/jobs/src/config/env.js delete mode 100644 apps/jobs/src/config/storage.js delete mode 100644 apps/jobs/src/index.js delete mode 100644 apps/jobs/src/processors/emails/index.js delete mode 100644 apps/jobs/src/processors/emails/send.js delete mode 100644 apps/jobs/src/processors/media/extract-audio.js delete mode 100644 apps/jobs/src/processors/media/generate-thumbnail.js delete mode 100644 apps/jobs/src/processors/media/index.js delete mode 100644 apps/jobs/src/processors/media/probe-metadata.js delete mode 100644 apps/jobs/src/processors/media/transcribe-audio.js delete mode 100644 apps/jobs/src/services/transcription/openai.js delete mode 100644 apps/jobs/src/services/transcription/vtt.js delete mode 100644 apps/jobs/src/utils/cancel.js delete mode 100644 apps/jobs/src/utils/ffmpeg.js delete mode 100644 apps/jobs/src/utils/logger.js delete mode 100644 apps/jobs/src/utils/storage.js create mode 100644 apps/jobs/src/workers/agent-course-generation.ts delete mode 100644 apps/jobs/src/workers/emails.js delete mode 100644 apps/jobs/src/workers/maintenance.js delete mode 100644 apps/jobs/src/workers/media-transcribe.js delete mode 100644 apps/jobs/src/workers/media.js delete mode 100644 apps/website/src/lib/components/more-features.svelte create mode 100644 apps/website/src/lib/utils/userjot/index.ts create mode 100644 apps/website/src/lib/utils/userjot/types.d.ts create mode 100644 apps/website/src/routes/public-courses/+page.svelte create mode 100644 apps/website/src/routes/roadmap/+page.server.ts delete mode 100644 apps/website/src/routes/roadmap/+page.svelte delete mode 100644 apps/website/static/ai-course-builder.jpeg delete mode 100644 apps/website/static/beautiful-academy-site.jpeg delete mode 100644 apps/website/static/cio-hero.webp delete mode 100644 apps/website/static/classroomio-courses.png delete mode 100644 apps/website/static/classroomio-opengraph-image_launch.png delete mode 100644 apps/website/static/claude-logo.svg delete mode 100644 apps/website/static/codex-logo.png delete mode 100644 apps/website/static/compliance-dashboard.jpeg delete mode 100644 apps/website/static/course-compliance.jpeg delete mode 100644 apps/website/static/cursor-logo.png delete mode 100644 apps/website/static/customizable-academy.jpeg delete mode 100644 apps/website/static/discord-blue.png delete mode 100644 apps/website/static/docs-vs-customer-site.jpeg delete mode 100644 apps/website/static/download-pdf.png delete mode 100644 apps/website/static/embeddable-widget.jpeg delete mode 100644 apps/website/static/free-tools/pomodoro/settings-icon.svg delete mode 100644 apps/website/static/github-mark.png delete mode 100644 apps/website/static/hero-1.svg delete mode 100644 apps/website/static/hero-2.svg delete mode 100644 apps/website/static/hero-3.svg delete mode 100644 apps/website/static/hero-4.svg delete mode 100644 apps/website/static/hero-5.svg delete mode 100644 apps/website/static/hero-academy.jpeg delete mode 100644 apps/website/static/hero-section.png delete mode 100644 apps/website/static/landing-page-builder.webp delete mode 100644 apps/website/static/latest-courses.jpeg delete mode 100644 apps/website/static/ng.png delete mode 100644 apps/website/static/opencode-logo.svg delete mode 100644 apps/website/static/org-compliance.jpeg delete mode 100644 apps/website/static/prime_twitter.svg delete mode 100644 apps/website/static/public-courses.jpeg delete mode 100644 apps/website/static/roadmap-one.svg delete mode 100644 apps/website/static/roadmap-two.svg delete mode 100644 apps/website/static/socials-avatar.png delete mode 100644 apps/website/static/teach/get-paid.svg delete mode 100644 apps/website/static/teach/inspire-learners.svg delete mode 100644 apps/website/static/teach/star.svg delete mode 100644 apps/website/static/teach/teach-from-anywhere.svg delete mode 100644 apps/website/static/teach/teacher-banner.png delete mode 100644 apps/website/static/teach/teacher-hero.png delete mode 100644 apps/website/static/teach/thumbs-up.svg delete mode 100644 apps/website/static/teach/user-profile.png delete mode 100644 apps/website/static/team-management.png delete mode 100644 apps/website/static/twitter.svg delete mode 100644 apps/website/static/video-transcription.png create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/scripts/restore-bare-specifiers.mjs create mode 100644 packages/core/src/config/dashboard-url.ts rename {apps/api => packages/core}/src/config/env.ts (100%) rename {apps/api => packages/core}/src/config/storage.ts (99%) create mode 100644 packages/core/src/index.ts rename {apps/api => packages/core}/src/services/agent/agent-tool-schemas.ts (93%) create mode 100644 packages/core/src/services/agent/balance-answer-positions.ts rename {apps/api => packages/core}/src/services/agent/chat-context.ts (97%) rename {apps/api => packages/core}/src/services/agent/chat-tools.ts (69%) rename {apps/api => packages/core}/src/services/agent/context-window.ts (100%) create mode 100644 packages/core/src/services/agent/course-generation-runner.ts rename {apps/api => packages/core}/src/services/agent/document.ts (93%) rename {apps/api => packages/core}/src/services/agent/fetch-url.ts (97%) rename {apps/api => packages/core}/src/services/agent/lesson-content.ts (100%) rename {apps/api => packages/core}/src/services/agent/model-context.ts (97%) rename {apps/api => packages/core}/src/services/agent/question-update.ts (98%) rename {apps/api => packages/core}/src/services/agent/sanitize-tool-calls.ts (100%) rename {apps/api => packages/core}/src/services/agent/summarize.ts (99%) rename {apps/api => packages/core}/src/services/agent/usage.ts (99%) create mode 100644 packages/core/src/services/assets/assets.ts rename {apps/api => packages/core}/src/services/course/content.ts (97%) rename {apps/api => packages/core}/src/services/course/course.ts (99%) rename {apps/api => packages/core}/src/services/course/go-live-readiness.ts (96%) rename {apps/api => packages/core}/src/services/course/landing-page.ts (90%) rename {apps/api => packages/core}/src/services/course/public-course-guard.ts (97%) rename {apps/api => packages/core}/src/services/course/section.ts (99%) rename {apps/api => packages/core}/src/services/course/slug.ts (95%) create mode 100644 packages/core/src/services/course/utils.ts rename {apps/api => packages/core}/src/services/exercise/exercise.ts (99%) create mode 100644 packages/core/src/services/exercise/utils.ts create mode 100644 packages/core/src/services/jobs/media-jobs.ts rename {apps/api => packages/core}/src/services/lesson-language.ts (97%) rename {apps/api => packages/core}/src/services/lesson/lesson.ts (97%) rename {apps/api => packages/core}/src/utils/lesson-media.ts (85%) rename {apps/api => packages/core}/src/utils/presigned-urls.ts (100%) create mode 100644 packages/core/src/utils/redis-keys.ts rename {apps/api => packages/core}/src/utils/redis/redis.ts (97%) rename {apps/api => packages/core}/src/utils/s3.ts (98%) rename {apps/api => packages/core}/src/utils/sanitize-html.ts (100%) rename {apps/api => packages/core}/src/utils/tinybird.ts (100%) rename {apps/api => packages/core}/src/utils/upload.ts (100%) create mode 100644 packages/core/tsconfig.json create mode 100644 packages/db/src/auth/login-link.ts create mode 100644 packages/db/src/queries/organization/link-invite.ts create mode 100644 packages/jobs/src/enqueue/agent-course-generation.ts create mode 100644 packages/jobs/src/payloads/agent-course-generation.ts create mode 100644 packages/storybook/src/templates/org-landing-page/course-landing-page.stories.svelte create mode 100644 packages/ui/src/custom/org-landing-page/README.md create mode 100644 packages/ui/src/custom/org-landing-page/bold/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/bold/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/bold/index.ts rename packages/ui/src/custom/org-landing-page/{bold.svelte => bold/org.svelte} (76%) create mode 100644 packages/ui/src/custom/org-landing-page/bold/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/classic/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/classic/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/classic/index.ts rename packages/ui/src/custom/org-landing-page/{classic.svelte => classic/org.svelte} (77%) create mode 100644 packages/ui/src/custom/org-landing-page/classic/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/corporate/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/corporate/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/corporate/index.ts rename packages/ui/src/custom/org-landing-page/{corporate.svelte => corporate/org.svelte} (81%) create mode 100644 packages/ui/src/custom/org-landing-page/corporate/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/course-chips.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-curriculum.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-info-blocks.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-instructor.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-landing-page.helpers.ts create mode 100644 packages/ui/src/custom/org-landing-page/course-landing-page.tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/course-pricing.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-reviews.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-section-nav.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-social-proof.svelte create mode 100644 packages/ui/src/custom/org-landing-page/course-tokens-base.ts create mode 100644 packages/ui/src/custom/org-landing-page/editorial/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/editorial/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/editorial/index.ts rename packages/ui/src/custom/org-landing-page/{editorial.svelte => editorial/org.svelte} (91%) create mode 100644 packages/ui/src/custom/org-landing-page/editorial/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/minimal/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/minimal/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/minimal/index.ts rename packages/ui/src/custom/org-landing-page/{minimal.svelte => minimal/org.svelte} (76%) create mode 100644 packages/ui/src/custom/org-landing-page/minimal/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/saas/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/saas/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/saas/index.ts rename packages/ui/src/custom/org-landing-page/{saas.svelte => saas/org.svelte} (82%) create mode 100644 packages/ui/src/custom/org-landing-page/saas/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/studio/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/studio/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/studio/index.ts rename packages/ui/src/custom/org-landing-page/{studio.svelte => studio/org.svelte} (82%) create mode 100644 packages/ui/src/custom/org-landing-page/studio/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/tech/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/tech/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/tech/index.ts rename packages/ui/src/custom/org-landing-page/{tech.svelte => tech/org.svelte} (81%) create mode 100644 packages/ui/src/custom/org-landing-page/tech/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/terminal/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/terminal/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/terminal/index.ts rename packages/ui/src/custom/org-landing-page/{terminal.svelte => terminal/org.svelte} (89%) create mode 100644 packages/ui/src/custom/org-landing-page/terminal/vars.ts create mode 100644 packages/ui/src/custom/org-landing-page/theme-vars-base.ts create mode 100644 packages/ui/src/custom/org-landing-page/vibrant/course-tokens.ts create mode 100644 packages/ui/src/custom/org-landing-page/vibrant/course.svelte create mode 100644 packages/ui/src/custom/org-landing-page/vibrant/index.ts rename packages/ui/src/custom/org-landing-page/{vibrant.svelte => vibrant/org.svelte} (93%) create mode 100644 packages/ui/src/custom/org-landing-page/vibrant/vars.ts create mode 100644 packages/utils/src/errors/index.ts create mode 100644 packages/utils/src/functions/transcript-vtt.ts create mode 100644 packages/utils/src/license/plan-features.ts create mode 100644 prd/copy-page-as-markdown/README.md create mode 100644 prd/scorm-support/README.md create mode 100644 prd/scorm-support/implementation-plan.md diff --git a/.env.example b/.env.example index 718b2963..7f3c2868 100644 --- a/.env.example +++ b/.env.example @@ -12,14 +12,6 @@ POSTGRES_DB=classroomio POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres -# Public URL where the API is reachable from browsers -# Local: http://localhost:3081 | Production: https://api.yourdomain.com -PUBLIC_SERVER_URL=http://localhost:3081 - -# Comma-separated origins allowed by CORS -# Local: http://localhost:3082 | Production: https://app.yourdomain.com -TRUSTED_ORIGINS=http://localhost:3082,http://localhost:5173 - # Auth secret for session signing (use a long random string in production) BETTER_AUTH_SECRET=replace-with-a-long-random-secret @@ -42,9 +34,21 @@ PRIVATE_SERVER_URL=http://api:3081 # When set, invite emails and other links point here instead of app.classroomio.com DASHBOARD_ORIGIN=http://localhost:3082 +# Optional direct API origin. Dashboard browser calls are proxied through +# DASHBOARD_ORIGIN, so normal self-hosted auth does not need this to share a +# cookie domain with the dashboard. +# Local: http://localhost:3081 | Production fallback: https://api.yourdomain.com +PUBLIC_SERVER_URL=http://localhost:3081 + +# Optional comma-separated origins allowed when browsers call the API directly. +# Normal self-hosted dashboard auth/API traffic is same-origin through the dashboard proxy. +# DASHBOARD_ORIGIN is trusted automatically. +TRUSTED_ORIGINS= + # CSP: external domains allowed by Content-Security-Policy. # These are runtime env vars — set them when running the container (no rebuild needed). -# Comma-separated. Include your CDN, API, video, and font domains. +# Comma-separated. Include your CDN, video, and font domains. You do not need +# to add the API here for normal dashboard traffic because API calls are proxied same-origin. # ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below. ALLOWED_EXTERNAL_DOMAINS= # Per-directive overrides (optional, comma-separated): @@ -96,10 +100,6 @@ GOOGLE_CLIENT_SECRET= # PUBLIC_GOOGLE_PICKER_CLIENT_ID= # PUBLIC_GOOGLE_PICKER_API_KEY= -# Cookie domain for auth. Set to your root domain (e.g. .yourdomain.com) when API and dashboard use different subdomains. -# Without this, auth cookies may not be set and login will fail. -AUTH_COOKIE_DOMAIN= - # Dashboard domain routing PRIVATE_APP_HOST=localhost PRIVATE_APP_SUBDOMAINS=app diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index 753eb532..c0fa5065 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -13,12 +13,14 @@ Use this file to keep a single prioritized view of all PRDs. | 5 | 3 | `prd/course-cohorts` | todo | | 6 | 7 | `prd/email-template-customization` | todo | | 7 | - | `prd/selective-exercise-assignment` | todo | +| - | - | `prd/scorm-support` | todo | | - | 4 | `prd/course-content-search` | todo | | - | 9 | `prd/onboarding-ai-bootstrap` | todo | | - | 10 | `prd/pricing-improvements` | todo | | - | 12 | `prd/public-courses` | todo | | - | - | `prd/customer-course-documentation` | todo | | - | - | `prd/walkthrough-widget` | draft | +| - | - | `prd/copy-page-as-markdown` | todo | | - | 15 | `prd/webhooks` | todo | ## DONE (Completed PRDs) diff --git a/README.md b/README.md index bdb45390..979c2934 100644 --- a/README.md +++ b/README.md @@ -8,35 +8,46 @@

-[![Codacy Badge](https://api.codacy.com/project/badge/Grade/629e2bb8994345729513c4d69ccbe3d5)](https://app.codacy.com/gh/classroomio/classroomio?utm_source=github.com&utm_medium=referral&utm_content=classroomio/classroomio&utm_campaign=Badge_Grade) - ## ✨ About ClassroomIO.com -ClassroomIO Courses page +ClassroomIO Courses page -Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device. +ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification programs — all from one platform. Self-host it on your own infrastructure or use the cloud version. -### Mission: Provide Students with the Best Learning Experience +### Who It's For -At ClassroomIO, our mission is to provide students with the best possible learning experience. We believe in empowering educators with the tools they need to deliver high-quality education that is accessible, engaging, and effective. +| Use Case | What You Get | +|---|---| +| **Compliance Training** | Deadline tracking, renewals & retake intervals, grace periods, waivers, certificates with custom IDs | +| **Customer Education** | Branded academy portal, programs (cohorts with goals), AI lesson tutor, multilingual content | +| **Partner Training** | Partner workspaces, branded certificates, custom domains, embeddable widgets, multilingual support | ### Key Features -1. **📚 Advanced Course Management:** You can create unlimited courses, create lessons, invite students, add assignments, grade their assignments, and even generate certificates. -2. **👨‍👩‍👦 Multi-Teacher Management:** You can invite other teachers into your organization and assign them individual courses. -3. **🤖 AI Integration:** We've got OpenAI integration for quick course creation where you can generate course content, lesson outlines, and even generate assignments right from your lesson notes. -4. **💬 Forum:** Students can ask questions in your dedicated community and get answers from either you or other students. -5. **💻 Dedicated Student Dashboard:** Once you create an account, you get a dedicated dashboard where your students can access all their courses, assignments, and more. -6. **🔒 Fully open source:** You can self-host the entire stack on your servers. +**Course & Content** +- **Course management** — unlimited courses, lessons, exercises, grading, and certificates +- **Programs** — group courses into cohorts with goals, team management, and progress tracking +- **AI Course Builder** — generate outlines, lesson content, and assignments (Gemini, GPT-4o, Claude) +- **AI Lesson Tutor** — in-lesson AI assistant that helps learners as they study -### Roadmap Features +**Compliance & Certification** +- **Compliance tracking** — status, deadlines, grace periods, renewals, and waivers +- **Certificates** — issue branded certificates with custom IDs -1. **Forms:** Instead of using Google Forms to collect vital information from your students, you will be able to create forms directly within the dashboard. -2. **Course Templates:** You can clone a full course or share templates with other people. -3. **Analytics:** You can track data about your students across multiple courses. -4. **Run Courses on Messengers:** Students can just join a channel on slack/discord/telegram and a bot automatically sends daily lesson content to your students without you doing anything. +**Learner & Org Experience** +- **Multi-org & multi-teacher** — invite teachers, assign courses, manage multiple organizations +- **Student dashboard** — learners access all courses, assignments, and progress in one place +- **Multilingual** — deliver content in 10+ languages -Please reach out to me on [twitter](https://x.com/rotimi_best) if you have any feature request. +**Integrations & Developer Tools** +- **REST API + Webhooks** — enroll users, trigger automations, receive events (`certificate.issued`, `enrollment.completed`, and more) +- **MCP server** — `@classroomio/mcp` on npm for AI-native integrations +- **Embeddable widget** — embed your course catalog on any website + +**Platform** +- **Fully open source** — self-host the entire stack on your own servers + +For what's coming next, see the [public roadmap](https://classroomio.com/roadmap). ## Built With @@ -119,9 +130,9 @@ The repository also contains shared packages under `packages/` (for example `pac - Go to `apps/dashboard` and `apps/api`. - Duplicate the `.env.example` file and rename it to `.env` - - Populate them with required values (at minimum): + - Populate them with required values (at minimum): - `apps/api/.env`: `DATABASE_URL`, `REDIS_URL`, `AUTH_BEARER_TOKEN`, `BETTER_AUTH_SECRET` - - `apps/dashboard/.env`: `PUBLIC_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED` + - `apps/dashboard/.env`: `PRIVATE_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED`, `DASHBOARD_ORIGIN` - Optional for self-hosted Enterprise-only features (SSO, token-auth, no-tracking): set `LICENSE_KEY` in `apps/api/.env` 6. Start local infrastructure for API (Postgres + Redis) and seed the DB: diff --git a/apps/api/.env.example b/apps/api/.env.example index 53f64650..0ad0bf06 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -41,6 +41,14 @@ CLOUDFLARE_ACCOUNT_ID="" CLOUDFLARE_BUCKET_DOMAIN="" CLOUDFLARE_SECRET_ACCESS_KEY="" +# HMAC secret used to sign the `cio_hls` playback cookie. The same value must +# be set on the tenant-router Worker (`wrangler secret put HLS_SIGNING_SECRET`) +# so segments served from R2 at the edge verify against this signature. +# Without it the cookie endpoints return 503 and HLS playback won't start — +# uploads still work, so leave it unset for upload-only local development. +# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +HLS_SIGNING_SECRET="" + # Sentry (server-side error tracking). Leave SENTRY_DSN unset to disable. # Skipped automatically in dev (NODE_ENV !== 'production') and self-hosted (PUBLIC_IS_SELFHOSTED=true). SENTRY_DSN="" diff --git a/apps/api/README.md b/apps/api/README.md index 92072dbc..41dfcebd 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -5,6 +5,86 @@ 1. Certificate download for students. 2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription) +## Running locally with video upload + playback + +Lesson videos run through an HLS pipeline on Cloud. The browser encodes a +360/720/1080 ABR ladder with Mediabunny, uploads segments to R2 via presigned +PUTs, and the player loads `/hls/{assetId}/master.m3u8` from the API. + +How `/hls/*` resolves depends on the environment: + +| | URL the browser hits | Who serves the bytes | +| ----------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Cloud production | `https:///hls/...` | Cloudflare Worker (`apps/tenant-router`) — intercepts the path and streams directly from an R2 binding. Render is never in the bytestream. | +| Local dev / self-hosted | `${PUBLIC_SERVER_URL}/hls/...` (direct to API) | Hono streaming route on the API — session-auth, then `getS3Client().send(GetObjectCommand)`. | + +**Self-hosted (`PUBLIC_IS_SELFHOSTED=true`) skips HLS entirely** and falls +back to raw-MP4 upload — no R2-specific code path. Everything below is for +Cloud-style local development. + +### Required env + +`apps/api/.env`: + +```bash +# Object storage — Cloudflare R2 OR an S3-compatible local backend (MinIO, etc.) +CLOUDFLARE_ACCOUNT_ID=… +CLOUDFLARE_ACCESS_KEY=… +CLOUDFLARE_SECRET_ACCESS_KEY=… +# (or OBJECT_STORAGE_ENDPOINT / OBJECT_STORAGE_ACCESS_KEY_ID / OBJECT_STORAGE_SECRET_ACCESS_KEY) + +# Optional — only needed if you want extract-audio → Whisper transcripts +OPENAI_API_KEY=… + +# Optional — Redis enables the BullMQ media job pipeline. Without it the +# asset row is still created, but no transcription/thumbnail jobs run. +REDIS_URL=redis://localhost:6379 + +# Only required when you want to exercise the production cookie-mint path +# (`POST /organization/assets/:assetId/hls/cookie`). Locally the Hono /hls/* +# route uses session auth, so most of the time you can leave this unset. +# Same value must also be set as a Cloudflare secret on tenant-router for prod. +HLS_SIGNING_SECRET=… +``` + +`apps/dashboard/.env`: + +```bash +# Already required by the existing API client — used as the HLS base URL too. +PUBLIC_SERVER_URL=http://localhost:3000 +``` + +### Run + +```bash +pnpm --filter @cio/api dev +pnpm --filter @cio/dashboard dev +# (optional) pnpm --filter @cio/jobs dev # for transcription + thumbnail jobs +``` + +Drop a video into a lesson; the dashboard encodes locally, you'll see +segments arrive under `videos/{assetId}/hls/…` in R2 and an `assets` row with +`hls_manifest_key` populated. Playback flows directly browser → API → R2 — +**no wrangler dev needed locally.** + +### Production setup (one-time) + +For Cloud deployment, generate the HMAC signing key the Worker uses to verify +playback cookies and set it on both sides: + +```bash +node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +``` + +```bash +# On Render (apps/api service env): +HLS_SIGNING_SECRET= + +# On Cloudflare (apps/tenant-router): +pnpm --filter @cio/tenant-router exec wrangler secret put HLS_SIGNING_SECRET +# paste the same value when prompted +``` + ## Debugging Fly.io View logs from fly.io in real time diff --git a/apps/api/package.json b/apps/api/package.json index 49820da9..8a976cfb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,8 +40,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.388.0", "@aws-sdk/s3-request-presigner": "^3.675.0", - "@bull-board/api": "^7.0.0", - "@bull-board/hono": "^7.0.0", + "@cio/core": "workspace:*", "@cio/ai-assistant": "workspace:*", "@cio/analytics": "workspace:*", "@cio/certificates": "workspace:*", @@ -50,6 +49,7 @@ "@cio/jobs": "workspace:*", "@cio/question-types": "workspace:*", "@cio/utils": "workspace:*", + "@getworkbench/hono": "0.2.1", "@hono/node-server": "^1.19.7", "@hono/standard-validator": "^0.1.5", "@hono/zod-validator": "^0.7.6", diff --git a/apps/api/src/__tests__/agent-lesson-content.test.ts b/apps/api/src/__tests__/agent-lesson-content.test.ts index 15c0629a..81cd2a89 100644 --- a/apps/api/src/__tests__/agent-lesson-content.test.ts +++ b/apps/api/src/__tests__/agent-lesson-content.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeAgentLessonContent } from '../services/agent/lesson-content'; +import { normalizeAgentLessonContent } from '@cio/core/services/agent/lesson-content'; describe('normalizeAgentLessonContent', () => { it('removes a leading heading that duplicates the lesson title', () => { diff --git a/apps/api/src/__tests__/ai-credits-usage.test.ts b/apps/api/src/__tests__/ai-credits-usage.test.ts index 0899212e..12127b0d 100644 --- a/apps/api/src/__tests__/ai-credits-usage.test.ts +++ b/apps/api/src/__tests__/ai-credits-usage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { computePurchasedTokenOverflow } from '@api/services/agent/usage'; +import { computePurchasedTokenOverflow } from '@cio/core/services/agent/usage'; describe('computePurchasedTokenOverflow', () => { it('returns 0 when usage stays within allowance', () => { diff --git a/apps/api/src/__tests__/balance-answer-positions.test.ts b/apps/api/src/__tests__/balance-answer-positions.test.ts new file mode 100644 index 00000000..415701b2 --- /dev/null +++ b/apps/api/src/__tests__/balance-answer-positions.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { balanceCorrectAnswerPositions } from '@cio/core/services/agent/balance-answer-positions'; +import { QUESTION_TYPE } from '@cio/utils/validation/constants'; + +type Option = { label: string; isCorrect: boolean }; +type Question = { questionTypeId: number; question: string; options: Option[] }; + +function radio(question: string, labels: string[], correctIndex: number): Question { + return { + questionTypeId: QUESTION_TYPE.RADIO, + question, + options: labels.map((label, i) => ({ label, isCorrect: i === correctIndex })) + }; +} + +function correctPosition(question: Question): number { + return question.options.findIndex((option) => option.isCorrect); +} + +describe('balanceCorrectAnswerPositions', () => { + it('spreads the correct RADIO position across the batch instead of clustering', () => { + // 12 questions, all authored with the answer at index 0 ("A is always right"). + const questions = Array.from({ length: 12 }, (_, i) => radio(`Q${i}`, ['alpha', 'bravo', 'charlie', 'delta'], 0)); + + const balanced = balanceCorrectAnswerPositions(questions); + const counts = [0, 0, 0, 0]; + balanced.forEach((question) => counts[correctPosition(question)]++); + + // 12 questions / 4 options → exactly 3 per position when perfectly balanced. + expect(counts).toEqual([3, 3, 3, 3]); + }); + + it('keeps the same correct option, only its position changes', () => { + const questions = [radio('Q', ['alpha', 'bravo', 'charlie', 'delta'], 0)]; + const [balanced] = balanceCorrectAnswerPositions(questions); + + const correct = balanced.options.find((option) => option.isCorrect); + expect(correct?.label).toBe('alpha'); + expect(balanced.options).toHaveLength(4); + expect(new Set(balanced.options.map((o) => o.label))).toEqual(new Set(['alpha', 'bravo', 'charlie', 'delta'])); + // Exactly one correct option survives. + expect(balanced.options.filter((o) => o.isCorrect)).toHaveLength(1); + }); + + it('is deterministic for identical input', () => { + const build = () => Array.from({ length: 8 }, (_, i) => radio(`Q${i}`, ['a', 'b', 'c', 'd'], 0)); + + const first = balanceCorrectAnswerPositions(build()).map(correctPosition); + const second = balanceCorrectAnswerPositions(build()).map(correctPosition); + expect(first).toEqual(second); + }); + + it('does not shuffle options that reference other positions', () => { + const questions = [radio('Q', ['Paris', 'London', 'Both of the above', 'All of the above'], 3)]; + const [balanced] = balanceCorrectAnswerPositions(questions); + + expect(balanced.options.map((o) => o.label)).toEqual(['Paris', 'London', 'Both of the above', 'All of the above']); + }); + + it('leaves naturally-ordered numeric ranges untouched', () => { + const questions = [radio('Range?', ['0-10', '11-20', '21-30', '31-40'], 1)]; + const [balanced] = balanceCorrectAnswerPositions(questions); + + expect(balanced.options.map((o) => o.label)).toEqual(['0-10', '11-20', '21-30', '31-40']); + }); + + it('balances each option-count group independently', () => { + const threeOption = Array.from({ length: 6 }, (_, i) => radio(`T${i}`, ['x', 'y', 'z'], 0)); + const fourOption = Array.from({ length: 8 }, (_, i) => radio(`F${i}`, ['a', 'b', 'c', 'd'], 0)); + + const balanced = balanceCorrectAnswerPositions([...threeOption, ...fourOption]); + + const threeCounts = [0, 0, 0]; + balanced.slice(0, 6).forEach((q) => threeCounts[correctPosition(q)]++); + expect(threeCounts).toEqual([2, 2, 2]); + + const fourCounts = [0, 0, 0, 0]; + balanced.slice(6).forEach((q) => fourCounts[correctPosition(q)]++); + expect(fourCounts).toEqual([2, 2, 2, 2]); + }); + + it('shuffles CHECKBOX options without dropping correct answers', () => { + // Correct answers authored at the top; expect them not all clustered after shuffle. + const questions: Question[] = Array.from({ length: 10 }, (_, i) => ({ + questionTypeId: QUESTION_TYPE.CHECKBOX, + question: `C${i}`, + options: [ + { label: 'one', isCorrect: true }, + { label: 'two', isCorrect: true }, + { label: 'three', isCorrect: false }, + { label: 'four', isCorrect: false } + ] + })); + + const balanced = balanceCorrectAnswerPositions(questions); + + balanced.forEach((question) => { + expect(question.options.filter((o) => o.isCorrect)).toHaveLength(2); + expect(new Set(question.options.map((o) => o.label))).toEqual(new Set(['one', 'two', 'three', 'four'])); + }); + + // Index 0 should not be correct in every single question once shuffled. + const indexZeroAlwaysCorrect = balanced.every((q) => q.options[0].isCorrect); + expect(indexZeroAlwaysCorrect).toBe(false); + }); + + it('leaves TRUE_FALSE and malformed questions untouched', () => { + const trueFalse: Question = { + questionTypeId: QUESTION_TYPE.TRUE_FALSE, + question: 'TF', + options: [ + { label: 'True', isCorrect: true }, + { label: 'False', isCorrect: false } + ] + }; + const noCorrect = radio('Q', ['a', 'b', 'c'], -1); + + const [balancedTf, balancedNone] = balanceCorrectAnswerPositions([trueFalse, noCorrect]); + expect(balancedTf.options.map((o) => o.label)).toEqual(['True', 'False']); + expect(balancedNone.options.map((o) => o.label)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/apps/api/src/__tests__/course-go-live-readiness.test.ts b/apps/api/src/__tests__/course-go-live-readiness.test.ts index b3777431..9e293704 100644 --- a/apps/api/src/__tests__/course-go-live-readiness.test.ts +++ b/apps/api/src/__tests__/course-go-live-readiness.test.ts @@ -4,13 +4,13 @@ import { getCourseById } from '@cio/db/queries/course'; import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content'; import { getCourseOrganizationId } from '@cio/db/queries/tag'; import { getOrganizationById } from '@cio/db/queries/organization'; -import { updateCourse } from '../services/course/course'; -import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page'; +import { updateCourse } from '@cio/core/services/course/course'; +import { ensureCourseSlug, generateUniqueCourseSlug } from '@cio/core/services/course/landing-page'; import { evaluateCourseGoLiveReadiness, getCourseGoLiveReadiness, publishCourseWhenReady -} from '../services/course/go-live-readiness'; +} from '@cio/core/services/course/go-live-readiness'; vi.mock('@cio/db/queries/course', () => ({ getCourseById: vi.fn() @@ -28,16 +28,19 @@ vi.mock('@cio/db/queries/organization', () => ({ getOrganizationById: vi.fn() })); -vi.mock('../services/course/course', () => ({ +vi.mock('@cio/core/services/course/course', () => ({ updateCourse: vi.fn() })); -vi.mock('../services/course/landing-page', () => ({ - buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'), +vi.mock('@cio/core/services/course/landing-page', () => ({ ensureCourseSlug: vi.fn(), generateUniqueCourseSlug: vi.fn() })); +vi.mock('@cio/core/config/dashboard-url', () => ({ + getDashboardBaseUrl: vi.fn(() => 'https://school.example.com') +})); + const mockedGetCourseById = vi.mocked(getCourseById); const mockedGetCourseContentItems = vi.mocked(getCourseContentItems); const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId); diff --git a/apps/api/src/__tests__/question-update.test.ts b/apps/api/src/__tests__/question-update.test.ts index 134bad0d..9745e599 100644 --- a/apps/api/src/__tests__/question-update.test.ts +++ b/apps/api/src/__tests__/question-update.test.ts @@ -3,7 +3,7 @@ import { buildUpdatedQuestions, type CurrentQuestionForPatch, type QuestionPatch -} from '@api/services/agent/question-update'; +} from '@cio/core/services/agent/question-update'; import { QUESTION_TYPE } from '@cio/utils/validation/constants'; const EXERCISE_ID = 'ex-1'; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 37d16874..5737ebd7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -20,8 +20,10 @@ import { jobsRouter } from '@api/routes/jobs'; import { licenseRouter } from '@api/routes/license'; import { logger } from 'hono/logger'; // ROUTES +import { hlsRouter } from '@api/routes/hls'; import { mailRouter } from '@api/routes/mail'; import { mediaRouter } from '@api/routes/media'; +import { transcriptsRouter } from '@api/routes/transcripts'; import { mountQueueDashboard } from '@api/routes/admin/queues'; import { onboardingRouter } from '@api/routes/onboarding'; import { organizationRouter } from '@api/routes/organization'; @@ -43,7 +45,13 @@ export const app = new Hono() // Middleware .use('*', logger()) .use('*', prettyJSON()) - .use('*', secureHeaders()) + // `Cross-Origin-Resource-Policy: same-origin` (Hono's default) blocks the + // dashboard from embedding api-served media (e.g. HLS segments via + // `