From d6e812d08b826d8de533395f18ab3ee5d6af91a7 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 12 Apr 2026 08:58:41 +0100 Subject: [PATCH] feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests. --- .../plans/lesson_ai_features_8bcf8cf2.plan.md | 531 ++++ .../plans/public_api_tests_89047418.plan.md | 219 ++ .env.example | 4 + .github/workflows/deploy-embeds.yml | 61 + PRD-TRACKER.md | 1 + apps/api/scripts/README.md | 11 +- apps/api/scripts/upload-openapi-spec.ts | 40 +- apps/api/src/app.ts | 4 + .../src/middlewares/automation-key-scopes.ts | 35 + apps/api/src/middlewares/course-member.ts | 6 + apps/api/src/middlewares/program-member.ts | 64 + ...program-newsfeed-comment-author-or-team.ts | 101 + .../src/middlewares/program-team-member.ts | 65 + apps/api/src/middlewares/rate-limiter.ts | 5 +- apps/api/src/routes/invite/invite.ts | 74 +- .../src/routes/organization/organization.ts | 19 +- apps/api/src/routes/program/index.ts | 1 + apps/api/src/routes/program/program.ts | 487 ++++ apps/api/src/routes/v1/audience.ts | 299 ++ apps/api/src/routes/v1/courses.ts | 410 +++ apps/api/src/routes/v1/index.ts | 5 + apps/api/src/services/community.ts | 2 +- .../services/course-import/course-import.ts | 101 +- apps/api/src/services/course/course.ts | 104 +- apps/api/src/services/dash.ts | 35 +- apps/api/src/services/exercise/exercise.ts | 35 +- apps/api/src/services/lesson-language.ts | 46 + apps/api/src/services/lesson/lesson.ts | 20 +- apps/api/src/services/organization.ts | 185 +- .../api/src/services/organization/audience.ts | 300 +- .../services/organization/automation-usage.ts | 8 +- apps/api/src/services/organization/invite.ts | 236 +- apps/api/src/services/program/program.ts | 580 ++++ apps/api/src/services/v1/audience.ts | 64 + apps/api/src/services/v1/course.ts | 135 + apps/api/src/types/dash.ts | 7 +- apps/api/src/utils/errors.ts | 144 +- apps/api/src/utils/lesson-media.ts | 1 + apps/api/src/utils/org.ts | 17 +- apps/api/src/utils/redis/key-generators.ts | 21 +- apps/course-app/components.json | 1 - apps/course-app/package.json | 14 +- apps/course-app/postcss.config.js | 3 +- apps/course-app/src/app.css | 66 +- .../components/animation/blurin/blurin.svelte | 36 - .../animation/border-beam/border-beam.svelte | 35 - .../lib/components/animation/card/body.svelte | 21 - .../components/animation/card/ellipses.svelte | 13 - .../animation/dot-pattern/dot-pattern.svelte | 37 - .../gradual-spacing/gradual-spacing.svelte | 42 - .../animation/magic-card/magic-card.svelte | 72 - .../animation/meteors/meteors.svelte | 36 - .../shimmerbutton/shimmerbutton.svelte | 67 - .../animation/shinny-text/shinny-text.svelte | 24 - .../animation/sparkle/sparkle-text.svelte | 73 - .../animation/sparkle/sparkle.svelte | 35 - .../animation/square-card/body.svelte | 9 - .../animation/square-card/icon.svelte | 21 - .../buy-template/buy-template.svelte | 10 +- .../src/lib/components/install/install.svelte | 4 +- .../components/navigation/navigation.svelte | 4 +- .../template-card/custom-template.svelte | 3 +- .../components/ui/separator/separator.svelte | 2 +- .../lib/components/ui/sonner/sonner.svelte | 2 +- apps/course-app/src/routes/+page.svelte | 23 +- apps/course-app/svelte.config.js | 14 +- apps/course-app/tailwind.config.ts | 128 - apps/dashboard/.env.example | 4 + apps/dashboard/package.json | 2 +- apps/dashboard/scripts/translate.cjs | 22 +- apps/dashboard/src/app.css | 2 + .../components/audience-table-toolbar.svelte | 64 +- .../features/audience/pages/audience.svelte | 27 +- .../lib/features/audience/pages/import.svelte | 72 +- .../audience/pages/user-analytics.svelte | 92 +- .../automation/api/automation.svelte.ts | 3 +- .../lib/features/automation/pages/api.svelte | 221 +- .../lib/features/automation/pages/mcp.svelte | 105 +- .../community/api/community.svelte.ts | 4 +- .../community/pages/community-question.svelte | 1 + .../features/community/pages/community.svelte | 4 +- .../lib/features/course/api/courses.svelte.ts | 81 +- .../features/course/api/newsfeed.svelte.ts | 50 +- .../features/course/components/card.svelte | 178 +- .../components/course-filter-popover.svelte | 178 +- .../course/components/course-list-row.svelte | 261 ++ .../components/exercise/order-modal.svelte | 51 +- .../components/exercise/question-labels.ts | 71 + .../components/exercise/view-mode.svelte | 18 +- .../lib/features/course/components/index.ts | 1 + .../lesson/video/add-video-modal.svelte | 4 + .../components/lesson/video/constants.ts | 6 + .../lesson/video/google-drive-video.svelte | 152 ++ .../lesson/video/video-card-utils.ts | 10 + .../components/lesson/video/video-card.svelte | 24 +- .../features/course/components/list.svelte | 11 +- .../components/newsfeed/card/card.svelte | 15 +- .../components/newsfeed/card/header.svelte | 18 +- .../components/newsfeed/card/reactions.svelte | 43 - .../components/people/invitation-modal.svelte | 74 +- .../features/course/pages/attendance.svelte | 2 +- .../lib/features/course/pages/courses.svelte | 106 +- .../lib/features/course/pages/exercise.svelte | 2 +- .../lib/features/course/pages/newsfeed.svelte | 38 +- .../src/lib/features/course/types.ts | 10 +- .../course/utils/course-list-row-utils.ts | 17 + .../src/lib/features/course/utils/store.ts | 2 +- .../lib/features/lms/api/exercises.server.ts | 20 +- .../components/pending-invite-modal.svelte | 90 + .../lib/features/lms/pages/dashboard.svelte | 49 +- .../src/lib/features/lms/utils/types.ts | 5 + .../media/utils/media-manager-utils.ts | 3 +- .../lib/features/org/api/dash-stats.svelte.ts | 6 +- .../lib/features/org/api/org-plan.server.ts | 56 +- .../src/lib/features/org/api/org.server.ts | 46 +- .../src/lib/features/org/api/org.svelte.ts | 4 +- .../landing-page/landing-page.svelte | 582 +--- .../components/settings/landing-page-store.ts | 117 +- .../org/utils/landing-page-components.ts | 29 + .../lib/features/org/utils/landing-page.ts | 397 +++ .../src/lib/features/org/utils/types.ts | 13 +- .../components/bulk-email-section.svelte | 68 + .../existing-students-section.svelte | 151 ++ .../lib/features/people/components/index.ts | 3 + .../components/tutor-select-section.svelte | 91 + .../src/lib/features/people/utils/types.ts | 9 + .../src/lib/features/program/api/index.ts | 2 + .../features/program/api/newsfeed.svelte.ts | 344 +++ .../features/program/api/program.svelte.ts | 330 +++ .../add-course-to-program-modal.svelte | 199 ++ .../components/create-program-modal.svelte | 73 + .../components/invite-members-modal.svelte | 248 ++ .../features/program/components/list.svelte | 91 + .../components/newsfeed/card/card.svelte | 129 + .../components/newsfeed/card/comments.svelte | 206 ++ .../newsfeed/card/delete-confirmation.svelte | 37 + .../components/newsfeed/card/header.svelte | 65 + .../program/components/newsfeed/index.ts | 2 + .../components/newsfeed/new-feed-modal.svelte | 105 + .../program/components/program-card.svelte | 45 + .../components/program-filter-popover.svelte | 141 + .../program/components/program-header.svelte | 25 + .../program/components/sidebar/index.ts | 1 + .../sidebar/program-sidebar-navigation.svelte | 105 + .../components/sidebar/program-sidebar.svelte | 71 + .../src/lib/features/program/pages/index.ts | 1 + .../features/program/pages/programs.svelte | 55 + .../lib/features/program/utils/constants.ts | 4 + .../src/lib/features/program/utils/error.ts | 25 + .../src/lib/features/program/utils/types.ts | 42 + .../features/settings/pages/billing.svelte | 17 +- .../features/settings/pages/domains.svelte | 4 +- .../src/lib/features/settings/pages/index.ts | 1 + .../settings/pages/landingpage-editor.svelte | 163 ++ .../button-link-fields.svelte | 58 + .../landingpage-editor/callout-section.svelte | 119 + .../landingpage-editor/embed-section.svelte | 133 + .../landingpage-editor/footer-section.svelte | 92 + .../landingpage-editor/hero-section.svelte | 148 + .../navigation-section.svelte | 70 + .../settings/pages/landingpage.svelte | 870 +----- .../src/lib/features/settings/utils/store.ts | 117 +- .../lib/features/tag/pages/tags-admin.svelte | 1 + .../src/lib/features/ui/auth-ui.svelte | 16 +- .../components/pricing-section.svelte | 106 +- .../course-landing-page.svelte | 569 ++-- apps/dashboard/src/lib/features/ui/index.ts | 3 + .../src/lib/features/ui/media-player/types.ts | 3 +- .../features/ui/navigation/lms-navigation.ts | 7 + .../features/ui/navigation/org-navigation.ts | 187 +- .../src/lib/features/ui/question/constants.ts | 7 +- .../ui/sidebar/org-sidebar/nav-main.svelte | 158 +- .../ui/sidebar/org-sidebar/org-sidebar.svelte | 2 +- .../src/lib/features/ui/sort-popover.svelte | 126 + .../src/lib/features/ui/sort-select.svelte | 46 + .../lib/features/ui/unsaved-changes.svelte | 4 +- .../ui/upload-widget/upload-widget.svelte | 2 +- .../lib/features/ui/visit-org-site-btn.svelte | 31 +- apps/dashboard/src/lib/utils/csp-domains.js | 11 +- .../utils/functions/google-drive-picker.ts | 167 ++ .../src/lib/utils/services/api/base.svelte.ts | 2 + .../services/api/parse-api-error-body.ts | 36 + .../src/lib/utils/services/api/server.ts | 63 + .../src/lib/utils/translations/da.json | 419 ++- .../src/lib/utils/translations/de.json | 419 ++- .../src/lib/utils/translations/en.json | 392 ++- .../src/lib/utils/translations/es.json | 419 ++- .../src/lib/utils/translations/fr.json | 419 ++- .../src/lib/utils/translations/hi.json | 419 ++- .../src/lib/utils/translations/pl.json | 419 ++- .../src/lib/utils/translations/pt.json | 419 ++- .../src/lib/utils/translations/ru.json | 419 ++- .../src/lib/utils/translations/vi.json | 419 ++- apps/dashboard/src/lib/utils/types/org.ts | 154 +- .../dashboard/src/routes/(app)/+layout.svelte | 118 + apps/dashboard/src/routes/(app)/+layout.ts | 3 + .../courses/[id]/+layout.server.ts | 0 .../{ => (app)}/courses/[id]/+layout.svelte | 0 .../{ => (app)}/courses/[id]/+page.svelte | 0 .../routes/{ => (app)}/courses/[id]/+page.ts | 0 .../courses/[id]/analytics/+page.server.ts | 17 +- .../courses/[id]/analytics/+page.svelte | 0 .../courses/[id]/attendance/+page.svelte | 2 +- .../courses/[id]/attendance/+page.ts | 0 .../courses/[id]/certificates/+page.svelte | 0 .../courses/[id]/certificates/+page.ts | 0 .../exercises/[exerciseId]/+page.server.ts | 42 + .../[id]/exercises/[exerciseId]/+page.svelte | 0 .../courses/[id]/landingpage/+page.svelte | 0 .../courses/[id]/landingpage/+page.ts | 0 .../courses/[id]/lessons/+page.svelte | 0 .../{ => (app)}/courses/[id]/lessons/+page.ts | 0 .../[id]/lessons/[lessonId]/+page.server.ts | 23 + .../[id]/lessons/[lessonId]/+page.svelte | 0 .../courses/[id]/marks/+page.server.ts | 10 +- .../courses/[id]/marks/+page.svelte | 0 .../courses/[id]/people/+layout.svelte | 0 .../courses/[id]/people/+layout.ts | 0 .../courses/[id]/people/+page.svelte | 0 .../{ => (app)}/courses/[id]/people/+page.ts | 0 .../[id]/people/[personId]/+page.server.ts | 19 +- .../[id]/people/[personId]/+page.svelte | 0 .../courses/[id]/settings/+page.svelte | 0 .../courses/[id]/settings/+page.ts | 0 .../courses/[id]/submissions/+page.server.ts | 39 + .../courses/[id]/submissions/+page.svelte | 0 .../src/routes/{ => (app)}/home/+page.svelte | 0 .../src/routes/{ => (app)}/lms/+layout.svelte | 0 .../src/routes/(app)/lms/+page.svelte | 59 + .../{ => (app)}/lms/community/+page.svelte | 0 .../lms/community/[slug]/+page.svelte | 0 .../{ => (app)}/lms/community/[slug]/+page.ts | 0 .../lms/community/ask/+page.svelte | 0 .../{ => (app)}/lms/exercises/+page.svelte | 0 .../{ => (app)}/lms/explore/+page.svelte | 0 .../{ => (app)}/lms/mylearning/+page.svelte | 0 .../routes/(app)/lms/programs/+page.svelte | 52 + .../{ => (app)}/lms/settings/+layout.svelte | 0 .../{ => (app)}/lms/settings/+page.svelte | 0 .../lms/settings/integrations/+page.svelte | 0 .../{ => (app)}/org/[slug]/+layout.server.ts | 0 .../{ => (app)}/org/[slug]/+layout.svelte | 0 .../{ => (app)}/org/[slug]/+page.server.ts | 17 +- .../{ => (app)}/org/[slug]/+page.svelte | 108 +- .../(app)/org/[slug]/api/+page.server.ts | 21 + .../routes/(app)/org/[slug]/api/+page.svelte | 41 + .../(app)/org/[slug]/audience/+page.server.ts | 47 + .../org/[slug]/audience/+page.svelte | 1 + .../audience/[...params]/+page.server.ts | 31 + .../[slug]/audience/[...params]/+page.svelte | 28 + .../[slug]/audience/import/+page.server.ts | 33 + .../org/[slug]/audience/import/+page.svelte | 9 +- .../org/[slug]/automation/+page.ts | 2 +- .../org/[slug]/community/+page.server.ts | 23 + .../org/[slug]/community/+page.svelte | 1 + .../[slug]/community/[cslug]/+page.server.ts | 18 + .../org/[slug]/community/[cslug]/+page.svelte | 0 .../org/[slug]/community/ask/+page.svelte | 1 + .../org/[slug]/courses/+page.server.ts | 33 +- .../org/[slug]/courses/+page.svelte | 1 + .../(app)/org/[slug]/mcp/+page.server.ts | 27 + .../org/[slug]}/mcp/+page.svelte | 4 +- .../org/[slug]/media/+page.server.ts | 21 +- .../{ => (app)}/org/[slug]/media/+page.svelte | 1 + .../(app)/org/[slug]/programs/+page.server.ts | 23 + .../(app)/org/[slug]/programs/+page.svelte | 156 ++ .../{ => (app)}/org/[slug]/quiz/+page.svelte | 0 .../org/[slug]/quiz/[slug]/+page.svelte | 0 .../org/[slug]/quiz/[slug]/+page.ts | 0 .../org/[slug]/settings/+layout.svelte | 0 .../org/[slug]/settings/+page.svelte | 1 + .../org/[slug]/settings/auth/+page.svelte | 1 + .../org/[slug]/settings/auth/sso/+page.svelte | 1 + .../settings/auth/token-auth/+page.svelte | 1 + .../org/[slug]/settings/billing/+page.svelte | 1 + .../settings/customize-lms/+page.svelte | 1 + .../org/[slug]/settings/domains/+page.svelte | 1 + .../[slug]/settings/integrations/+page.svelte | 1 + .../[slug]/settings/landingpage/+page.svelte | 9 +- .../settings/landingpage/edit/+page.svelte | 125 + .../org/[slug]/settings/org/+page.svelte | 1 + .../org/[slug]/settings/teams/+page.svelte | 1 + .../(app)/org/[slug]/setup/+page.server.ts | 20 + .../{ => (app)}/org/[slug]/setup/+page.svelte | 0 .../(app)/org/[slug]/tags/+page.server.ts | 28 + .../{ => (app)}/org/[slug]/tags/+page.svelte | 0 .../(app)/org/[slug]/zapier/+page.svelte | 24 + .../(app)/programs/[id]/+layout.server.ts | 5 + .../routes/(app)/programs/[id]/+layout.svelte | 60 + .../(app)/programs/[id]/courses/+page.svelte | 209 ++ .../(app)/programs/[id]/newsfeed/+page.svelte | 179 ++ .../(app)/programs/[id]/people/+page.svelte | 306 +++ .../(app)/programs/[id]/settings/+page.svelte | 140 + .../src/routes/(org-site)/+layout.svelte | 50 + .../src/routes/(org-site)/+layout.ts | 3 + .../(org-site)/course/[slug]/+page.server.ts | 87 + .../(org-site)/course/[slug]/+page.svelte | 110 + .../course/[slug]/enroll/+page.server.ts | 102 + .../course/[slug]/enroll/+page.svelte | 2 +- .../routes/(org-site)/courses/+page.server.ts | 60 + .../{ => (org-site)}/courses/+page.svelte | 90 +- apps/dashboard/src/routes/+error.svelte | 28 +- apps/dashboard/src/routes/+layout.svelte | 132 +- apps/dashboard/src/routes/+page.server.ts | 46 + apps/dashboard/src/routes/+page.svelte | 121 +- .../src/routes/_dashboard-entry.svelte | 62 + .../src/routes/course/[slug]/+page.svelte | 16 - .../src/routes/course/[slug]/+page.ts | 61 - .../course/[slug]/enroll/+page.server.ts | 106 - .../src/routes/courses/+page.server.ts | 50 - .../exercises/[exerciseId]/+page.server.ts | 40 - .../[id]/lessons/[lessonId]/+page.server.ts | 25 - .../courses/[id]/submissions/+page.server.ts | 38 - .../routes/invite/[hash]/+layout.server.ts | 28 +- apps/dashboard/src/routes/lms/+page.svelte | 27 - .../org/[slug]/audience/+page.server.ts | 41 - .../audience/[...params]/+page.server.ts | 30 - .../[slug]/audience/[...params]/+page.svelte | 18 - .../[slug]/audience/import/+page.server.ts | 19 - .../org/[slug]/automation/api/+page.svelte | 22 - .../org/[slug]/automation/mcp/+page.server.ts | 23 - .../org/[slug]/automation/zapier/+page.svelte | 22 - .../org/[slug]/community/+page.server.ts | 22 - .../[slug]/community/[cslug]/+page.server.ts | 19 - .../routes/org/[slug]/setup/+page.server.ts | 21 - .../routes/org/[slug]/tags/+page.server.ts | 26 - apps/dashboard/vite.config.ts | 11 +- apps/docs/.gitignore | 1 + apps/docs/app/app.css | 5 + apps/docs/app/components/mdx.tsx | 15 + .../{src => app}/components/not-found.tsx | 4 +- apps/docs/{src => app}/lib/layout.shared.tsx | 4 +- apps/docs/app/lib/source.server.ts | 35 + apps/docs/app/root.tsx | 134 + apps/docs/app/routes.ts | 8 + apps/docs/app/routes/api-reference.ts | 18 + .../{src/routes/$.tsx => app/routes/docs.tsx} | 90 +- apps/docs/app/routes/home.tsx | 5 + apps/docs/app/routes/search.ts | 10 + apps/docs/content/docs/api.mdx | 64 + .../adding-translation-to-code.mdx | 2 +- apps/docs/content/docs/meta.json | 2 + apps/docs/content/docs/programs.mdx | 10 + apps/docs/nitro.config.ts | 10 - apps/docs/package.json | 22 +- .../programs/lms-programs-placeholder.svg | 9 + .../programs/program-courses-placeholder.svg | 11 + .../programs/program-newsfeed-placeholder.svg | 10 + .../programs/program-people-placeholder.svg | 12 + .../programs/program-settings-placeholder.svg | 10 + .../programs/program-sidebar-placeholder.svg | 14 + .../programs/programs-list-placeholder.svg | 14 + .../programs-overview-placeholder.svg | 10 + apps/docs/react-router.config.ts | 5 + apps/docs/src/lib/source.ts | 15 - apps/docs/src/router.tsx | 13 - apps/docs/src/routes/__root.tsx | 118 - apps/docs/src/routes/api/search.ts | 16 - apps/docs/src/routes/index.tsx | 12 - apps/docs/src/styles/app.css | 7 - apps/docs/tsconfig.json | 4 +- apps/docs/vite.config.ts | 13 +- apps/embeds/README.md | 130 + apps/embeds/cdn.html | 16 + apps/embeds/example-embed.html | 24 + apps/embeds/index.html | 34 + apps/embeds/package.json | 32 + apps/embeds/scripts/upload-embeds.ts | 267 ++ apps/embeds/src/embed.css | 6 + apps/embeds/src/shared/mount.ts | 20 + .../widgets/question-type-picker/bootstrap.ts | 118 + .../question-type-picker/embed-mount.ts | 22 + .../question-type-picker/widget.svelte | 10 + apps/embeds/svelte.config.js | 8 + apps/embeds/tsconfig.json | 16 + apps/embeds/vite.config.ts | 50 + .../src/lib/components/ai-section.svelte | 117 + .../lib/components/automate-section.svelte | 57 + .../src/lib/components/cert-section.svelte | 57 + .../src/lib/components/deploy-section.svelte | 72 + .../lib/components/enterprise-section.svelte | 263 ++ .../lib/components/features-section.svelte | 71 + .../src/lib/components/footer-cta.svelte | 24 + apps/website/src/lib/components/footer.svelte | 21 +- apps/website/src/lib/components/hero.svelte | 317 +-- apps/website/src/lib/components/index.ts | 9 + .../src/lib/components/learner-section.svelte | 127 + .../src/lib/components/page-header.svelte | 9 +- .../src/lib/components/question-types.svelte | 23 + .../src/lib/components/testimonial.svelte | 24 +- .../src/lib/components/tools-header.svelte | 9 +- .../employee-training/+page.svelte | 70 +- .../routes/(redirects)/privacy/+layout.svelte | 29 + .../src/routes/(redirects)/privacy/+page.md | 249 ++ .../(redirects)/privacy/+page.server.ts | 11 +- .../src/routes/(redirects)/tos/+layout.svelte | 29 + .../src/routes/(redirects)/tos/+page.md | 136 + .../routes/(redirects)/tos/+page.server.ts | 10 +- apps/website/src/routes/+page.svelte | 41 +- apps/website/src/routes/pricing/+page.svelte | 2 +- apps/website/static/claude-logo.svg | 7 + apps/website/static/codex-logo.png | Bin 0 -> 354585 bytes apps/website/static/cursor-logo.png | Bin 0 -> 29610 bytes apps/website/static/opencode-logo.svg | 1 + apps/website/vite.config.js | 2 +- package.json | 7 +- packages/db/src/drizzle.ts | 2 +- packages/db/src/queries/course/course.ts | 278 +- packages/db/src/queries/dash/dash.ts | 60 +- packages/db/src/queries/dash/types.ts | 5 +- packages/db/src/queries/exercise/exercise.ts | 22 + packages/db/src/queries/group/group.ts | 65 +- packages/db/src/queries/index.ts | 1 + packages/db/src/queries/lesson/language.ts | 9 + packages/db/src/queries/newsfeed/newsfeed.ts | 2 +- .../db/src/queries/organization/invite.ts | 53 + .../src/queries/organization/organization.ts | 61 + packages/db/src/queries/program/index.ts | 1 + packages/db/src/queries/program/program.ts | 699 +++++ packages/db/src/schema.ts | 161 +- .../db/src/scripts/backfill-lesson-assets.ts | 2 +- packages/db/src/utils/seed/questionType.ts | 12 + packages/email/src/emails/index.ts | 3 + .../src/emails/student-program-welcome.ts | 26 + packages/email/src/templates/default.ts | 4 +- packages/email/src/utils/constants.ts | 1 + packages/mcp/src/api-client.ts | 2 +- packages/mcp/src/tools/course-drafts.ts | 2 +- .../question-types/ADDING_A_QUESTION_TYPE.md | 31 +- packages/question-types/src/answer-codecs.ts | 49 +- packages/question-types/src/answer-data.ts | 6 +- packages/question-types/src/exercise-types.ts | 25 + packages/question-types/src/index.ts | 1 + .../src/question-answer-payload.ts | 14 +- .../question-types/src/question-scoring.ts | 46 + .../question-types/src/question-type-keys.ts | 4 +- .../src/question-type-registry.ts | 18 + .../src/star-rating-settings.ts | 17 + .../tests/question-type-registry.test.ts | 16 + packages/storybook/package.json | 4 +- .../src/atoms/item/item.stories.svelte | 24 +- packages/storybook/src/index.css | 13 + .../animation/animation.stories.svelte | 176 ++ .../exercise-question.stories.svelte | 33 +- .../exercise-question/question-fixtures.ts | 35 + .../exercise-question/question-labels.ts | 25 + .../question-type-modes.svelte | 34 + .../newsfeed-reactions.stories.svelte | 112 + .../question-type-picker.stories.svelte | 27 + .../src/molecules/resource-list-row/fields.ts | 2 + .../resource-list-row.stories.svelte | 449 +++ .../templates/org-landing-page/fixtures.ts | 133 + .../org-landing-page.stories.svelte | 25 + packages/ui/README.md | 5 + packages/ui/package.json | 5 +- .../src/base/dialog/dialog-description.svelte | 13 +- .../ui/src/base/dialog/dialog-title.svelte | 7 +- .../ui/src/base/dialog/dialog-trigger.svelte | 6 +- packages/ui/src/base/item/item.svelte | 3 +- packages/ui/src/base/page/page-header.svelte | 6 +- .../animation/blurfade/blurfade.svelte | 30 +- .../src/custom}/animation/blurfade/index.ts | 0 .../src/custom/animation/blurin/blurin.svelte | 34 + .../ui/src/custom}/animation/blurin/index.ts | 0 .../animation/border-beam/border-beam.svelte | 36 + .../custom}/animation/border-beam/index.ts | 0 .../ui/src/custom/animation/card/body.svelte | 15 + .../custom}/animation/card/container.svelte | 20 +- .../src/custom/animation/card/ellipses.svelte | 13 + .../ui/src/custom}/animation/card/index.ts | 0 .../animation/dot-pattern/dot-pattern.svelte | 45 + .../custom}/animation/dot-pattern/index.ts | 0 .../gradual-spacing/gradual-spacing.svelte | 57 + .../animation/gradual-spacing/index.ts | 0 .../hero-video-dialog.svelte | 122 +- .../animation/hero-video-dialog/index.ts | 0 packages/ui/src/custom/animation/index.ts | 13 + .../src/custom}/animation/magic-card/index.ts | 0 .../animation/magic-card/magic-card.svelte | 78 + .../src/custom/animation/marketing-layers.css | 81 + .../ui/src/custom}/animation/meteors/index.ts | 0 .../custom/animation/meteors/meteors.svelte | 48 + .../custom}/animation/shimmerbutton/index.ts | 0 .../shimmerbutton/shimmerbutton.svelte | 67 + .../custom}/animation/shinny-text/index.ts | 0 .../animation/shinny-text/shinny-text.svelte | 24 + .../ui/src/custom}/animation/sparkle/index.ts | 0 .../animation/sparkle/sparkle-particle.svelte | 34 + .../animation/sparkle/sparkle-text.svelte | 72 + .../custom/animation/square-card/body.svelte | 15 + .../custom/animation/square-card/icon.svelte | 25 + .../custom}/animation/square-card/index.ts | 0 .../ui/src/custom/course-card/constants.ts | 1 + .../src/custom/course-card/course-card.svelte | 143 + packages/ui/src/custom/course-card/index.ts | 2 + .../exercise-question/renderer-contract.ts | 22 + .../renderers/link/preview.svelte | 24 +- .../renderers/star/edit.svelte | 101 + .../renderers/star/preview.svelte | 37 + .../renderers/star/star-row.svelte | 48 + .../renderers/star/submission.svelte | 14 + .../renderers/star/take.svelte | 46 + .../renderers/submission-generic.svelte | 2 + .../renderers/word-bank/edit.svelte | 142 + .../renderers/word-bank/preview.svelte | 42 + .../renderers/word-bank/review.svelte | 81 + .../renderers/word-bank/submission.svelte | 112 + .../renderers/word-bank/take.svelte | 210 ++ .../renderers/word-bank/word-bank-utils.ts | 99 + .../custom/media-player/media-player.svelte | 20 + packages/ui/src/custom/media-player/types.ts | 3 +- .../ui/src/custom/moving-icons/api.svelte | 109 + .../src/custom/moving-icons/dashboard.svelte | 67 +- .../custom/moving-icons/external-link.svelte | 81 + .../ui/src/custom/moving-icons/home.svelte | 32 +- packages/ui/src/custom/moving-icons/index.ts | 4 + .../ui/src/custom/moving-icons/tag.svelte | 77 + .../ui/src/custom/moving-icons/zap.svelte | 87 + .../ui/src/custom/newsfeed-reactions/index.ts | 22 + .../newsfeed-reactions.svelte | 102 + .../newsfeed-reactions/newsfeed-reactions.ts | 78 + .../src/custom/org-landing-page/bold.svelte | 145 + .../custom/org-landing-page/bold/hero.svelte | 45 + .../custom/org-landing-page/bold/nav.svelte | 43 + .../custom/org-landing-page/callout.svelte | 70 + .../custom/org-landing-page/classic.svelte | 152 ++ .../org-landing-page/classic/hero.svelte | 49 + .../org-landing-page/classic/nav.svelte | 47 + .../src/custom/org-landing-page/embed.svelte | 36 + .../ui/src/custom/org-landing-page/index.ts | 14 + .../landing-page-footer.svelte | 52 + .../org-landing-page/landing-page-utils.ts | 19 + .../custom/org-landing-page/minimal.svelte | 122 + .../org-landing-page/minimal/hero.svelte | 68 + .../org-landing-page/minimal/nav.svelte | 50 + .../secondary-action-button.svelte | 38 + .../ui/src/custom/org-landing-page/types.ts | 78 + .../question-type-picker/exercise-labels.ts | 147 + .../src/custom/question-type-picker/index.ts | 4 + .../question-type-picker/picker-data.ts | 308 +++ .../question-type-picker.svelte | 145 + .../ui/src/custom/resource-list-row/index.ts | 8 + .../resource-list-group.svelte | 21 + .../resource-list-row-end.svelte | 20 + .../resource-list-row-lead.svelte | 20 + .../resource-list-row-main.svelte | 20 + .../resource-list-row.svelte | 72 + packages/ui/src/index.css | 7 + packages/ui/src/index.ts | 5 + packages/ui/src/output.css | 2 +- packages/utils/src/constants/error-codes.ts | 156 ++ packages/utils/src/constants/index.ts | 1 + packages/utils/src/plans/data.ts | 12 +- .../utils/src/validation/assets/assets.ts | 3 +- .../validation/course-import/course-import.ts | 3 +- .../utils/src/validation/exercise/exercise.ts | 52 +- packages/utils/src/validation/index.ts | 2 + .../utils/src/validation/lesson/lesson.ts | 2 +- .../src/validation/organization/audience.ts | 18 +- .../validation/organization/organization.ts | 3 + .../utils/src/validation/program/index.ts | 1 + .../utils/src/validation/program/program.ts | 71 + .../src/validation/public-api/audience.ts | 27 + .../utils/src/validation/public-api/course.ts | 33 + .../utils/src/validation/public-api/index.ts | 2 + pnpm-lock.yaml | 2401 +++++++++-------- prd/ai-course-assistant/README.md | 938 +++++-- .../implementation-plan.md | 177 +- prd/compliance-training-platform/README.md | 1374 ++++------ prd/org-landing-page-theme-picker/README.md | 191 ++ .../implementation-plan.md | 92 + prd/programs/README.md | 1220 +++++---- prd/public-api/README.md | 566 ++-- 573 files changed, 31964 insertions(+), 8708 deletions(-) create mode 100644 .cursor/plans/lesson_ai_features_8bcf8cf2.plan.md create mode 100644 .cursor/plans/public_api_tests_89047418.plan.md create mode 100644 .github/workflows/deploy-embeds.yml create mode 100644 apps/api/src/middlewares/automation-key-scopes.ts create mode 100644 apps/api/src/middlewares/program-member.ts create mode 100644 apps/api/src/middlewares/program-newsfeed-comment-author-or-team.ts create mode 100644 apps/api/src/middlewares/program-team-member.ts create mode 100644 apps/api/src/routes/program/index.ts create mode 100644 apps/api/src/routes/program/program.ts create mode 100644 apps/api/src/routes/v1/audience.ts create mode 100644 apps/api/src/routes/v1/courses.ts create mode 100644 apps/api/src/routes/v1/index.ts create mode 100644 apps/api/src/services/program/program.ts create mode 100644 apps/api/src/services/v1/audience.ts create mode 100644 apps/api/src/services/v1/course.ts delete mode 100644 apps/course-app/src/lib/components/animation/blurin/blurin.svelte delete mode 100644 apps/course-app/src/lib/components/animation/border-beam/border-beam.svelte delete mode 100644 apps/course-app/src/lib/components/animation/card/body.svelte delete mode 100644 apps/course-app/src/lib/components/animation/card/ellipses.svelte delete mode 100644 apps/course-app/src/lib/components/animation/dot-pattern/dot-pattern.svelte delete mode 100644 apps/course-app/src/lib/components/animation/gradual-spacing/gradual-spacing.svelte delete mode 100644 apps/course-app/src/lib/components/animation/magic-card/magic-card.svelte delete mode 100644 apps/course-app/src/lib/components/animation/meteors/meteors.svelte delete mode 100644 apps/course-app/src/lib/components/animation/shimmerbutton/shimmerbutton.svelte delete mode 100644 apps/course-app/src/lib/components/animation/shinny-text/shinny-text.svelte delete mode 100644 apps/course-app/src/lib/components/animation/sparkle/sparkle-text.svelte delete mode 100644 apps/course-app/src/lib/components/animation/sparkle/sparkle.svelte delete mode 100644 apps/course-app/src/lib/components/animation/square-card/body.svelte delete mode 100644 apps/course-app/src/lib/components/animation/square-card/icon.svelte delete mode 100644 apps/course-app/tailwind.config.ts create mode 100644 apps/dashboard/src/lib/features/course/components/course-list-row.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/video/google-drive-video.svelte delete mode 100644 apps/dashboard/src/lib/features/course/components/newsfeed/card/reactions.svelte create mode 100644 apps/dashboard/src/lib/features/course/utils/course-list-row-utils.ts create mode 100644 apps/dashboard/src/lib/features/lms/components/pending-invite-modal.svelte create mode 100644 apps/dashboard/src/lib/features/lms/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/org/utils/landing-page-components.ts create mode 100644 apps/dashboard/src/lib/features/org/utils/landing-page.ts create mode 100644 apps/dashboard/src/lib/features/people/components/bulk-email-section.svelte create mode 100644 apps/dashboard/src/lib/features/people/components/existing-students-section.svelte create mode 100644 apps/dashboard/src/lib/features/people/components/index.ts create mode 100644 apps/dashboard/src/lib/features/people/components/tutor-select-section.svelte create mode 100644 apps/dashboard/src/lib/features/people/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/program/api/index.ts create mode 100644 apps/dashboard/src/lib/features/program/api/newsfeed.svelte.ts create mode 100644 apps/dashboard/src/lib/features/program/api/program.svelte.ts create mode 100644 apps/dashboard/src/lib/features/program/components/add-course-to-program-modal.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/create-program-modal.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/invite-members-modal.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/list.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/card/card.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/card/comments.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/card/delete-confirmation.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/card/header.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/index.ts create mode 100644 apps/dashboard/src/lib/features/program/components/newsfeed/new-feed-modal.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/program-card.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/program-filter-popover.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/program-header.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/sidebar/index.ts create mode 100644 apps/dashboard/src/lib/features/program/components/sidebar/program-sidebar-navigation.svelte create mode 100644 apps/dashboard/src/lib/features/program/components/sidebar/program-sidebar.svelte create mode 100644 apps/dashboard/src/lib/features/program/pages/index.ts create mode 100644 apps/dashboard/src/lib/features/program/pages/programs.svelte create mode 100644 apps/dashboard/src/lib/features/program/utils/constants.ts create mode 100644 apps/dashboard/src/lib/features/program/utils/error.ts create mode 100644 apps/dashboard/src/lib/features/program/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/button-link-fields.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/callout-section.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/embed-section.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/footer-section.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/hero-section.svelte create mode 100644 apps/dashboard/src/lib/features/settings/pages/landingpage-editor/navigation-section.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sort-popover.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sort-select.svelte create mode 100644 apps/dashboard/src/lib/utils/functions/google-drive-picker.ts create mode 100644 apps/dashboard/src/lib/utils/services/api/parse-api-error-body.ts create mode 100644 apps/dashboard/src/routes/(app)/+layout.svelte create mode 100644 apps/dashboard/src/routes/(app)/+layout.ts rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/+layout.server.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/+layout.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/analytics/+page.server.ts (55%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/analytics/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/attendance/+page.svelte (91%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/attendance/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/certificates/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/certificates/+page.ts (100%) create mode 100644 apps/dashboard/src/routes/(app)/courses/[id]/exercises/[exerciseId]/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/exercises/[exerciseId]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/landingpage/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/landingpage/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/lessons/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/lessons/+page.ts (100%) create mode 100644 apps/dashboard/src/routes/(app)/courses/[id]/lessons/[lessonId]/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/lessons/[lessonId]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/marks/+page.server.ts (52%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/marks/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/+layout.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/+layout.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/[personId]/+page.server.ts (57%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/people/[personId]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/settings/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/settings/+page.ts (100%) create mode 100644 apps/dashboard/src/routes/(app)/courses/[id]/submissions/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/courses/[id]/submissions/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/home/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/+layout.svelte (100%) create mode 100644 apps/dashboard/src/routes/(app)/lms/+page.svelte rename apps/dashboard/src/routes/{ => (app)}/lms/community/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/community/[slug]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/community/[slug]/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/community/ask/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/exercises/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/explore/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/mylearning/+page.svelte (100%) create mode 100644 apps/dashboard/src/routes/(app)/lms/programs/+page.svelte rename apps/dashboard/src/routes/{ => (app)}/lms/settings/+layout.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/settings/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/lms/settings/integrations/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/+layout.server.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/+layout.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/+page.server.ts (57%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/+page.svelte (65%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/api/+page.server.ts create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/api/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/audience/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/audience/+page.svelte (96%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/audience/[...params]/+page.server.ts create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/audience/[...params]/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/audience/import/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/audience/import/+page.svelte (71%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/automation/+page.ts (65%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/community/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/community/+page.svelte (91%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/community/[cslug]/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/community/[cslug]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/community/ask/+page.svelte (91%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/courses/+page.server.ts (52%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/courses/+page.svelte (98%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/mcp/+page.server.ts rename apps/dashboard/src/routes/{org/[slug]/automation => (app)/org/[slug]}/mcp/+page.svelte (87%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/media/+page.server.ts (60%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/media/+page.svelte (93%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/programs/+page.server.ts create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/programs/+page.svelte rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/quiz/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/quiz/[slug]/+page.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/quiz/[slug]/+page.ts (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/+layout.svelte (100%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/+page.svelte (93%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/auth/+page.svelte (86%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/auth/sso/+page.svelte (86%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/auth/token-auth/+page.svelte (86%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/billing/+page.svelte (86%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/customize-lms/+page.svelte (92%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/domains/+page.svelte (84%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/integrations/+page.svelte (86%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/landingpage/+page.svelte (71%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/settings/landingpage/edit/+page.svelte rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/org/+page.svelte (92%) rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/settings/teams/+page.svelte (86%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/setup/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/setup/+page.svelte (100%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/tags/+page.server.ts rename apps/dashboard/src/routes/{ => (app)}/org/[slug]/tags/+page.svelte (100%) create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/zapier/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/+layout.server.ts create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/+layout.svelte create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/courses/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/newsfeed/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/people/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/programs/[id]/settings/+page.svelte create mode 100644 apps/dashboard/src/routes/(org-site)/+layout.svelte create mode 100644 apps/dashboard/src/routes/(org-site)/+layout.ts create mode 100644 apps/dashboard/src/routes/(org-site)/course/[slug]/+page.server.ts create mode 100644 apps/dashboard/src/routes/(org-site)/course/[slug]/+page.svelte create mode 100644 apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.server.ts rename apps/dashboard/src/routes/{ => (org-site)}/course/[slug]/enroll/+page.svelte (99%) create mode 100644 apps/dashboard/src/routes/(org-site)/courses/+page.server.ts rename apps/dashboard/src/routes/{ => (org-site)}/courses/+page.svelte (66%) create mode 100644 apps/dashboard/src/routes/+page.server.ts create mode 100644 apps/dashboard/src/routes/_dashboard-entry.svelte delete mode 100644 apps/dashboard/src/routes/course/[slug]/+page.svelte delete mode 100644 apps/dashboard/src/routes/course/[slug]/+page.ts delete mode 100644 apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/exercises/[exerciseId]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/lessons/[lessonId]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/courses/[id]/submissions/+page.server.ts delete mode 100644 apps/dashboard/src/routes/lms/+page.svelte delete mode 100644 apps/dashboard/src/routes/org/[slug]/audience/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/audience/[...params]/+page.svelte delete mode 100644 apps/dashboard/src/routes/org/[slug]/audience/import/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/automation/api/+page.svelte delete mode 100644 apps/dashboard/src/routes/org/[slug]/automation/mcp/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/automation/zapier/+page.svelte delete mode 100644 apps/dashboard/src/routes/org/[slug]/community/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/community/[cslug]/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/setup/+page.server.ts delete mode 100644 apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts create mode 100644 apps/docs/app/app.css create mode 100644 apps/docs/app/components/mdx.tsx rename apps/docs/{src => app}/components/not-found.tsx (93%) rename apps/docs/{src => app}/lib/layout.shared.tsx (91%) create mode 100644 apps/docs/app/lib/source.server.ts create mode 100644 apps/docs/app/root.tsx create mode 100644 apps/docs/app/routes.ts create mode 100644 apps/docs/app/routes/api-reference.ts rename apps/docs/{src/routes/$.tsx => app/routes/docs.tsx} (56%) create mode 100644 apps/docs/app/routes/home.tsx create mode 100644 apps/docs/app/routes/search.ts create mode 100644 apps/docs/content/docs/api.mdx create mode 100644 apps/docs/content/docs/programs.mdx delete mode 100644 apps/docs/nitro.config.ts create mode 100644 apps/docs/public/programs/lms-programs-placeholder.svg create mode 100644 apps/docs/public/programs/program-courses-placeholder.svg create mode 100644 apps/docs/public/programs/program-newsfeed-placeholder.svg create mode 100644 apps/docs/public/programs/program-people-placeholder.svg create mode 100644 apps/docs/public/programs/program-settings-placeholder.svg create mode 100644 apps/docs/public/programs/program-sidebar-placeholder.svg create mode 100644 apps/docs/public/programs/programs-list-placeholder.svg create mode 100644 apps/docs/public/programs/programs-overview-placeholder.svg create mode 100644 apps/docs/react-router.config.ts delete mode 100644 apps/docs/src/lib/source.ts delete mode 100644 apps/docs/src/router.tsx delete mode 100644 apps/docs/src/routes/__root.tsx delete mode 100644 apps/docs/src/routes/api/search.ts delete mode 100644 apps/docs/src/routes/index.tsx delete mode 100644 apps/docs/src/styles/app.css create mode 100644 apps/embeds/README.md create mode 100644 apps/embeds/cdn.html create mode 100644 apps/embeds/example-embed.html create mode 100644 apps/embeds/index.html create mode 100644 apps/embeds/package.json create mode 100644 apps/embeds/scripts/upload-embeds.ts create mode 100644 apps/embeds/src/embed.css create mode 100644 apps/embeds/src/shared/mount.ts create mode 100644 apps/embeds/src/widgets/question-type-picker/bootstrap.ts create mode 100644 apps/embeds/src/widgets/question-type-picker/embed-mount.ts create mode 100644 apps/embeds/src/widgets/question-type-picker/widget.svelte create mode 100644 apps/embeds/svelte.config.js create mode 100644 apps/embeds/tsconfig.json create mode 100644 apps/embeds/vite.config.ts create mode 100644 apps/website/src/lib/components/ai-section.svelte create mode 100644 apps/website/src/lib/components/automate-section.svelte create mode 100644 apps/website/src/lib/components/cert-section.svelte create mode 100644 apps/website/src/lib/components/deploy-section.svelte create mode 100644 apps/website/src/lib/components/enterprise-section.svelte create mode 100644 apps/website/src/lib/components/features-section.svelte create mode 100644 apps/website/src/lib/components/footer-cta.svelte create mode 100644 apps/website/src/lib/components/learner-section.svelte create mode 100644 apps/website/src/lib/components/question-types.svelte create mode 100644 apps/website/src/routes/(redirects)/privacy/+layout.svelte create mode 100644 apps/website/src/routes/(redirects)/privacy/+page.md create mode 100644 apps/website/src/routes/(redirects)/tos/+layout.svelte create mode 100644 apps/website/src/routes/(redirects)/tos/+page.md create mode 100644 apps/website/static/claude-logo.svg create mode 100644 apps/website/static/codex-logo.png create mode 100644 apps/website/static/cursor-logo.png create mode 100644 apps/website/static/opencode-logo.svg create mode 100644 packages/db/src/queries/program/index.ts create mode 100644 packages/db/src/queries/program/program.ts create mode 100644 packages/email/src/emails/student-program-welcome.ts create mode 100644 packages/question-types/src/star-rating-settings.ts create mode 100644 packages/storybook/src/molecules/animation/animation.stories.svelte create mode 100644 packages/storybook/src/molecules/newsfeed-reactions/newsfeed-reactions.stories.svelte create mode 100644 packages/storybook/src/molecules/question-type-picker/question-type-picker.stories.svelte create mode 100644 packages/storybook/src/molecules/resource-list-row/fields.ts create mode 100644 packages/storybook/src/molecules/resource-list-row/resource-list-row.stories.svelte create mode 100644 packages/storybook/src/templates/org-landing-page/fixtures.ts create mode 100644 packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/blurfade/blurfade.svelte (63%) rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/blurfade/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/blurin/blurin.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/blurin/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/border-beam/border-beam.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/border-beam/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/card/body.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/card/container.svelte (50%) create mode 100644 packages/ui/src/custom/animation/card/ellipses.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/card/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/dot-pattern/dot-pattern.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/dot-pattern/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/gradual-spacing/gradual-spacing.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/gradual-spacing/index.ts (100%) rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/hero-video-dialog/hero-video-dialog.svelte (58%) rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/hero-video-dialog/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/index.ts rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/magic-card/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/magic-card/magic-card.svelte create mode 100644 packages/ui/src/custom/animation/marketing-layers.css rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/meteors/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/meteors/meteors.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/shimmerbutton/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/shimmerbutton/shimmerbutton.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/shinny-text/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/shinny-text/shinny-text.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/sparkle/index.ts (100%) create mode 100644 packages/ui/src/custom/animation/sparkle/sparkle-particle.svelte create mode 100644 packages/ui/src/custom/animation/sparkle/sparkle-text.svelte create mode 100644 packages/ui/src/custom/animation/square-card/body.svelte create mode 100644 packages/ui/src/custom/animation/square-card/icon.svelte rename {apps/course-app/src/lib/components => packages/ui/src/custom}/animation/square-card/index.ts (100%) create mode 100644 packages/ui/src/custom/course-card/constants.ts create mode 100644 packages/ui/src/custom/course-card/course-card.svelte create mode 100644 packages/ui/src/custom/course-card/index.ts create mode 100644 packages/ui/src/custom/exercise-question/renderers/star/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/star/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/star/star-row.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/star/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/star/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/edit.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/preview.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/review.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/submission.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/take.svelte create mode 100644 packages/ui/src/custom/exercise-question/renderers/word-bank/word-bank-utils.ts create mode 100644 packages/ui/src/custom/moving-icons/api.svelte create mode 100644 packages/ui/src/custom/moving-icons/external-link.svelte create mode 100644 packages/ui/src/custom/moving-icons/tag.svelte create mode 100644 packages/ui/src/custom/moving-icons/zap.svelte create mode 100644 packages/ui/src/custom/newsfeed-reactions/index.ts create mode 100644 packages/ui/src/custom/newsfeed-reactions/newsfeed-reactions.svelte create mode 100644 packages/ui/src/custom/newsfeed-reactions/newsfeed-reactions.ts create mode 100644 packages/ui/src/custom/org-landing-page/bold.svelte create mode 100644 packages/ui/src/custom/org-landing-page/bold/hero.svelte create mode 100644 packages/ui/src/custom/org-landing-page/bold/nav.svelte create mode 100644 packages/ui/src/custom/org-landing-page/callout.svelte create mode 100644 packages/ui/src/custom/org-landing-page/classic.svelte create mode 100644 packages/ui/src/custom/org-landing-page/classic/hero.svelte create mode 100644 packages/ui/src/custom/org-landing-page/classic/nav.svelte create mode 100644 packages/ui/src/custom/org-landing-page/embed.svelte create mode 100644 packages/ui/src/custom/org-landing-page/index.ts create mode 100644 packages/ui/src/custom/org-landing-page/landing-page-footer.svelte create mode 100644 packages/ui/src/custom/org-landing-page/landing-page-utils.ts create mode 100644 packages/ui/src/custom/org-landing-page/minimal.svelte create mode 100644 packages/ui/src/custom/org-landing-page/minimal/hero.svelte create mode 100644 packages/ui/src/custom/org-landing-page/minimal/nav.svelte create mode 100644 packages/ui/src/custom/org-landing-page/secondary-action-button.svelte create mode 100644 packages/ui/src/custom/org-landing-page/types.ts create mode 100644 packages/ui/src/custom/question-type-picker/exercise-labels.ts create mode 100644 packages/ui/src/custom/question-type-picker/index.ts create mode 100644 packages/ui/src/custom/question-type-picker/picker-data.ts create mode 100644 packages/ui/src/custom/question-type-picker/question-type-picker.svelte create mode 100644 packages/ui/src/custom/resource-list-row/index.ts create mode 100644 packages/ui/src/custom/resource-list-row/resource-list-group.svelte create mode 100644 packages/ui/src/custom/resource-list-row/resource-list-row-end.svelte create mode 100644 packages/ui/src/custom/resource-list-row/resource-list-row-lead.svelte create mode 100644 packages/ui/src/custom/resource-list-row/resource-list-row-main.svelte create mode 100644 packages/ui/src/custom/resource-list-row/resource-list-row.svelte create mode 100644 packages/utils/src/constants/error-codes.ts create mode 100644 packages/utils/src/validation/program/index.ts create mode 100644 packages/utils/src/validation/program/program.ts create mode 100644 packages/utils/src/validation/public-api/audience.ts create mode 100644 packages/utils/src/validation/public-api/course.ts create mode 100644 packages/utils/src/validation/public-api/index.ts create mode 100644 prd/org-landing-page-theme-picker/README.md create mode 100644 prd/org-landing-page-theme-picker/implementation-plan.md diff --git a/.cursor/plans/lesson_ai_features_8bcf8cf2.plan.md b/.cursor/plans/lesson_ai_features_8bcf8cf2.plan.md new file mode 100644 index 00000000..cdd27984 --- /dev/null +++ b/.cursor/plans/lesson_ai_features_8bcf8cf2.plan.md @@ -0,0 +1,531 @@ +--- +name: Lesson AI Features +overview: Add credit-based "Listen to Lesson" (TTS) and "Summarize" (LLM) features to lessons, with a dedicated AI credit system, Polar.sh credit purchases, and a teacher-managed AI tab in edit mode. +todos: + - id: openai-dep + content: Install openai package in apps/api and add OPENAI_API_KEY to env schema + status: pending + - id: db-tables + content: Add lesson_ai_cache, organization_ai_usage, organization_ai_credit_purchase tables to schema.ts and generate migration + status: pending + - id: db-queries + content: Create query functions for AI cache, AI usage tracking, and AI credit balance computation + status: pending + - id: ai-credits-constants + content: Create AI credit costs and per-plan limits in packages/utils/src/plans/ai-credits.ts + status: pending + - id: error-codes + content: Add AI-related error codes to ErrorCodes + status: pending + - id: ai-credit-service + content: Create ai-credits.ts service for balance checks, deduction, and Polar purchase handling + status: pending + - id: ai-service + content: Create lesson-ai.ts service with summarizeLesson and generateLessonAudio + status: pending + - id: validation + content: Add Zod schemas for AI endpoints (summarize, audio, cost-estimate, credit-balance) + status: pending + - id: api-routes + content: Add AI lesson routes and AI credit routes to the API + status: pending + - id: polar-webhook + content: Extend Polar webhook to handle AI credit pack purchases + status: pending + - id: api-build + content: Build API and verify RPC types propagate + status: pending + - id: fe-types + content: Add request/response types to course/utils/types.ts + status: pending + - id: fe-api-methods + content: Add AI methods to LessonApi class and create AiCreditsApi class + status: pending + - id: fe-ai-tab + content: Create lesson AI tab component for edit mode (generate/regenerate TTS and summaries) + status: pending + - id: fe-student-view + content: Create student-facing Listen and Summary components for view mode + status: pending + - id: fe-credit-ui + content: Add AI credit balance display and buy-credits flow in settings + status: pending + - id: fe-translations + content: Add translation keys for all AI feature strings + status: pending + - id: fe-integrate + content: Wire everything into lesson.svelte, export from barrels, final integration + status: pending +isProject: false +--- + +# Lesson AI Features: Listen to Lesson and Summarize + +## Architecture + +```mermaid +flowchart TD + subgraph teacherView [Teacher - Edit Mode] + AITab["AI tab in lesson editor"] + CostPreview["Credit cost per lesson"] + GenerateBtn["Generate / Regenerate buttons"] + end + + subgraph studentView [Student - View Mode] + ListenBtn["Listen to lesson button"] + SummaryPanel["AI Summary panel"] + AudioPlayer["Audio player"] + end + + subgraph creditSystem [AI Credit System] + BalanceCheck["Balance check"] + PlanCredits["Plan monthly credits"] + PurchasedCredits["Purchased credit packs"] + PolarCheckout["Polar.sh checkout"] + end + + subgraph api [API - Hono] + SummarizeRoute["POST lesson/:id/summarize"] + AudioRoute["POST lesson/:id/audio"] + CostRoute["GET lesson/:id/ai-cost"] + BalanceRoute["GET org/ai-credits"] + PurchaseRoute["Polar webhook"] + AIService["lesson-ai.ts"] + CreditService["ai-credits.ts"] + end + + subgraph storage [Storage] + Cache["lesson_ai_cache"] + UsageTable["organization_ai_usage"] + PurchaseTable["organization_ai_credit_purchase"] + S3["S3 audio files"] + end + + subgraph openai [OpenAI] + ChatAPI["GPT-4o-mini"] + TTSAPI["tts-1"] + end + + AITab --> CostPreview + AITab --> GenerateBtn + GenerateBtn -->|RPC| SummarizeRoute + GenerateBtn -->|RPC| AudioRoute + ListenBtn --> AudioPlayer + studentView -->|"reads cached"| Cache + + SummarizeRoute --> CreditService + AudioRoute --> CreditService + CreditService --> BalanceCheck + BalanceCheck --> PlanCredits + BalanceCheck --> PurchasedCredits + PolarCheckout -->|webhook| PurchaseRoute + PurchaseRoute --> PurchaseTable + + CreditService --> AIService + AIService --> Cache + AIService --> UsageTable + AIService -->|summary| ChatAPI + AIService -->|audio| TTSAPI + AIService -->|upload| S3 +``` + +## Credit System Design + +### Credit Costs + +| Action | Credits | Approx. OpenAI cost | +|--------|---------|---------------------| +| Generate summary | 1 | ~$0.0003 | +| Generate TTS audio | 5 | ~$0.05-0.08 | +| Regenerate (same costs) | same | same | + +### Per-Plan Monthly AI Credits + +| Plan | Monthly AI Credits | +|------|-------------------| +| BASIC (Free) | 5 | +| EARLY_ADOPTER | 50 | +| ENTERPRISE | 500 | + +### Purchased Credits + +- One-time credit packs via Polar.sh (e.g., 50 credits, 200 credits) +- Purchased credits never expire +- Plan credits are consumed first each month; purchased credits are used only when plan credits run out +- New Polar products need to be created for credit packs + +### Balance Computation + +``` +planCreditsRemaining = planMonthlyLimit - SUM(ai_usage WHERE source='plan' AND this billing month) +purchasedCreditsRemaining = SUM(ai_credit_purchase.credits) - SUM(ai_usage WHERE source='purchased') +totalAvailable = planCreditsRemaining + purchasedCreditsRemaining +``` + +When consuming credits: deduct from plan credits first. If plan credits are exhausted, deduct from purchased pool. The `source` column on `organization_ai_usage` tracks which pool was charged. + +--- + +## Phase 1: Backend Infrastructure + +### 1a. OpenAI dependency and env config + +- Install `openai` (latest) in [apps/api/package.json](apps/api/package.json) +- Add `OPENAI_API_KEY` (optional string) to the Zod env schema in [apps/api/src/config/env.ts](apps/api/src/config/env.ts) + +### 1b. Database tables + +Add to [packages/db/src/schema.ts](packages/db/src/schema.ts) and generate a migration. + +**Enum:** + +```typescript +export const lessonAiFeatureType = pgEnum('LESSON_AI_FEATURE_TYPE', ['summary', 'audio']); +export const aiCreditSource = pgEnum('AI_CREDIT_SOURCE', ['plan', 'purchased']); +``` + +**`lesson_ai_cache`** -- stores generated summaries and audio per lesson+locale: + +```typescript +export const lessonAiCache = pgTable('lesson_ai_cache', { + id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), + lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }), + locale: locale().default('en').notNull(), + featureType: lessonAiFeatureType('feature_type').notNull(), + content: text(), // markdown summary + audioAssetKey: text('audio_asset_key'), // S3 key for TTS audio + contentHash: text('content_hash').notNull(), // hash of source lesson HTML + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + unique('unique_lesson_locale_feature').on(table.lessonId, table.locale, table.featureType) +]); +``` + +**`organization_ai_usage`** -- tracks each AI generation event: + +```typescript +export const organizationAiUsage = pgTable('organization_ai_usage', { + id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), + organizationId: uuid('organization_id').notNull().references(() => organization.id), + lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }), + featureType: lessonAiFeatureType('feature_type').notNull(), + creditsConsumed: integer('credits_consumed').default(0).notNull(), + source: aiCreditSource('source').notNull(), // 'plan' or 'purchased' + metadata: jsonb('metadata').default({}).$type>().notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`).notNull() +}, (table) => [ + index('idx_org_ai_usage_org_created').on(table.organizationId, table.createdAt) +]); +``` + +**`organization_ai_credit_purchase`** -- tracks Polar credit pack purchases: + +```typescript +export const organizationAiCreditPurchase = pgTable('organization_ai_credit_purchase', { + id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(), + organizationId: uuid('organization_id').notNull().references(() => organization.id), + credits: integer('credits').notNull(), + polarOrderId: text('polar_order_id'), + metadata: jsonb('metadata').default({}).$type>().notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`).notNull() +}); +``` + +### 1c. Database queries + +New file: `packages/db/src/queries/organization/ai-credits.ts` + +- `getAiCreditsUsedThisMonth(orgId, source)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='plan'` AND `createdAt >= billingPeriodStart` +- `getTotalPurchasedCredits(orgId)` -- SUM of `credits` from `organization_ai_credit_purchase` +- `getTotalPurchasedCreditsUsed(orgId)` -- SUM of `credits_consumed` from `organization_ai_usage` WHERE `source='purchased'` +- `recordAiUsage(data)` -- insert into `organization_ai_usage` +- `recordAiCreditPurchase(data)` -- insert into `organization_ai_credit_purchase` + +New file: `packages/db/src/queries/lesson/ai-cache.ts` + +- `getLessonAiCache(lessonId, locale, featureType)` -- get cached entry +- `upsertLessonAiCache(data)` -- insert or update on conflict + +### 1d. AI credit constants + +New file: [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts) + +```typescript +export const AI_FEATURE_CREDIT_COST = { + summary: 1, + audio: 5, +} as const; + +export const AI_CREDITS_BY_PLAN: Record = { + [PLAN.BASIC]: { monthlyCredits: 5 }, + [PLAN.EARLY_ADOPTER]: { monthlyCredits: 50 }, + [PLAN.ENTERPRISE]: { monthlyCredits: 500 }, +}; +``` + +Re-export from `packages/utils/src/plans/index.ts`. + +### 1e. Error codes + +Add to [packages/utils/src/constants/error-codes.ts](packages/utils/src/constants/error-codes.ts): + +```typescript +AI_CREDITS_EXHAUSTED: 'AI_CREDITS_EXHAUSTED', +AI_FEATURE_UNAVAILABLE: 'AI_FEATURE_UNAVAILABLE', +AI_GENERATION_FAILED: 'AI_GENERATION_FAILED', +``` + +### 1f. AI credit service + +New file: `apps/api/src/services/organization/ai-credits.ts` + +- `getAiCreditBalance(orgId)` -- computes `planCreditsRemaining`, `purchasedCreditsRemaining`, `totalAvailable`, `monthlyLimit`, and returns the full balance object +- `assertAiCreditsAvailable(orgId, featureType)` -- checks totalAvailable >= cost, throws `AI_CREDITS_EXHAUSTED` (429) if insufficient +- `deductAiCredits(orgId, lessonId, featureType)` -- determines source (plan first, then purchased), inserts usage row +- `addPurchasedCredits(orgId, credits, polarOrderId)` -- inserts into `organization_ai_credit_purchase` + +### 1g. Lesson AI service + +New file: `apps/api/src/services/lesson/lesson-ai.ts` + +**`summarizeLesson(lessonId, locale, orgId)`** +1. Fetch lesson content from `lesson_language` for locale +2. Strip HTML to plain text, compute content hash +3. Check `lesson_ai_cache` -- if hash matches, return cached summary +4. Call `assertAiCreditsAvailable(orgId, 'summary')` +5. Call OpenAI Chat Completions (`gpt-4o-mini`) with summarization prompt +6. Upsert `lesson_ai_cache` with summary + hash +7. Call `deductAiCredits(orgId, lessonId, 'summary')` +8. Return summary + +**`generateLessonAudio(lessonId, locale, orgId)`** +1. Fetch lesson content, strip HTML, compute hash +2. Check cache -- if hash matches and audio key exists, return presigned URL +3. Call `assertAiCreditsAvailable(orgId, 'audio')` +4. Chunk text into segments under 4096 chars +5. Call OpenAI TTS (`tts-1`, voice `alloy`) for each chunk +6. Concatenate audio buffers, upload to S3 +7. Upsert cache with audio key + hash +8. Call `deductAiCredits(orgId, lessonId, 'audio')` +9. Return presigned URL + +**`estimateLessonAiCost(lessonId, locale)`** +1. Fetch lesson content, check what's already cached +2. Return `{ summaryCost, audioCost, totalCost, isStale }` -- 0 if already cached and fresh + +**`getLessonAiStatus(lessonId, locale)`** +1. Check cache for both summary and audio +2. Return `{ hasSummary, hasAudio, isSummaryStale, isAudioStale, summary?, audioUrl? }` + +Shared util: `stripHtmlToText(html)` using regex or a lightweight lib. + +### 1h. API routes + +**Extend lesson router** in [apps/api/src/routes/course/lesson.ts](apps/api/src/routes/course/lesson.ts): + +- `POST /:lessonId/summarize` -- auth + courseMember, role 1/2 only (teacher/admin), calls `summarizeLesson` +- `POST /:lessonId/audio` -- auth + courseMember, role 1/2 only, calls `generateLessonAudio` +- `GET /:lessonId/ai-status` -- auth + courseMember, calls `getLessonAiStatus` (used by both edit and view mode) +- `GET /:lessonId/ai-cost` -- auth + courseMember, role 1/2 only, calls `estimateLessonAiCost` + +**New org AI credit routes** (add to [apps/api/src/routes/organization/organization.ts](apps/api/src/routes/organization/organization.ts) or a new `ai-credits` sub-router): + +- `GET /ai-credits` -- auth, returns `getAiCreditBalance(orgId)` + +### 1i. Validation schemas + +Add to `packages/utils/src/validation/lesson/`: + +- `ZLessonAiParam` -- `{ lessonId: z.string().uuid() }` +- `ZLessonAiQuery` -- `{ locale: z.enum([...]).optional().default('en') }` + +--- + +## Phase 2: Polar Credit Purchases + +### 2a. Create Polar products + +Create credit pack products in Polar dashboard (not code -- manual setup): +- "50 AI Credits" -- one-time purchase +- "200 AI Credits" -- one-time purchase + +### 2b. Credit pack constants + +Add product IDs to [packages/utils/src/plans/ai-credits.ts](packages/utils/src/plans/ai-credits.ts): + +```typescript +export const AI_CREDIT_PACKS = { + SMALL: { credits: 50, productId: '' }, + LARGE: { credits: 200, productId: '' }, +}; +``` + +### 2c. Polar webhook extension + +Extend [apps/dashboard/src/routes/api/polar/webhook/+server.ts](apps/dashboard/src/routes/api/polar/webhook/+server.ts): + +- Add a new `order.created` (or `checkout.updated` with `status === 'succeeded'`) handler +- Match product ID against `AI_CREDIT_PACKS` to determine credits +- Call a new API route to add credits: `POST /organization/ai-credits/purchase` +- That route calls `addPurchasedCredits(orgId, credits, polarOrderId)` + +### 2d. Checkout flow + +Reuse the existing Polar checkout pattern from [apps/dashboard/src/routes/api/polar/subscribe/+server.ts](apps/dashboard/src/routes/api/polar/subscribe/+server.ts) -- create a similar `GET /api/polar/ai-credits` endpoint that creates a one-time checkout (not subscription) with `metadata: { orgId, type: 'ai-credits', credits: N }`. + +--- + +## Phase 3: Frontend -- Teacher Edit Mode + +### 3a. Types + +Add to [apps/dashboard/src/lib/features/course/utils/types.ts](apps/dashboard/src/lib/features/course/utils/types.ts): + +```typescript +export type SummarizeLessonRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].summarize.$post; +export type GenerateLessonAudioRequest = typeof classroomio.course[':courseId'].lesson[':lessonId'].audio.$post; +export type GetLessonAiStatusRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-status']['$get']; +export type GetLessonAiCostRequest = typeof classroomio.course[':courseId'].lesson[':lessonId']['ai-cost']['$get']; +``` + +### 3b. LessonApi extensions + +Add to [apps/dashboard/src/lib/features/course/api/lesson.svelte.ts](apps/dashboard/src/lib/features/course/api/lesson.svelte.ts): + +- State: `aiStatus`, `aiCost`, `isSummarizing`, `isGeneratingAudio` +- Methods: `getAiStatus(courseId, lessonId, locale)`, `getAiCost(courseId, lessonId, locale)`, `summarize(courseId, lessonId, locale)`, `generateAudio(courseId, lessonId, locale)` + +### 3c. AI credit API class + +New file: `apps/dashboard/src/lib/features/course/api/ai-credits.svelte.ts` + +- State: `balance` (plan remaining, purchased remaining, total) +- Methods: `getBalance()`, `purchaseCredits(packId)` (redirects to Polar checkout) + +### 3d. Lesson AI tab (edit mode) + +New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/ai-tab.svelte` + +This is a new tab (value `5`) added to the lesson edit tabs in [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts) alongside Note, Slide, Video, Document. Uses a sparkle/wand icon. + +Layout: +- **Credit balance bar** at top: "AI Credits: 42 remaining (plan: 35, purchased: 7)" with a "Buy more" link to settings +- **Summary section:** + - Status: "Not generated" / "Generated" / "Stale (content changed)" + - "Generate Summary" button showing cost (e.g., "1 credit") + - If generated: preview of the summary text, "Regenerate" button +- **Audio section:** + - Status: same pattern + - "Generate Audio" button showing cost (e.g., "5 credits") + - If generated: inline audio player, "Regenerate" button + - If stale: warning that content has changed since last generation + +### 3e. Integrate AI tab + +In [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte): +- Add AI tab to the `UnderlineTabs` in edit mode +- Load `aiStatus` and `aiCost` when entering the AI tab (lazy load) +- Load `aiCredits.balance` when entering the AI tab + +In [constants.ts](apps/dashboard/src/lib/features/course/components/lesson/constants.ts): +- Add `{ label: 'course.navItem.lessons.materials.tabs.ai.title', icon: SparklesIcon, value: 5, badgeValue: 0 }` + +--- + +## Phase 4: Frontend -- Student View Mode + +### 4a. Student toolbar + +New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-ai-toolbar.svelte` + +Placed in the lesson view mode block in [lesson.svelte](apps/dashboard/src/lib/features/course/pages/lesson.svelte), between the note content and the Comments section. Shown only when `aiStatus` has generated content. + +- **"Listen to lesson"** button (headphones icon, dark bg) -- shown only when `aiStatus.hasAudio` is true. Toggles an inline audio player. +- **"Summarize"** button (zap icon, outline) -- shown only when `aiStatus.hasSummary` is true. Toggles the summary panel below. + +### 4b. Audio player + +New component: `apps/dashboard/src/lib/features/course/components/lesson/ai/lesson-audio-player.svelte` + +- Native `