From 0031a6021dc1c543fd6ef511ae657d98cbf3027e Mon Sep 17 00:00:00 2001 From: willwearing Date: Tue, 8 Sep 2026 09:15:03 -0600 Subject: [PATCH 1/3] fix: address prelaunch quality audit and signup alerting --- .github/workflows/ci-deploy.yml | 151 +++-- .github/workflows/publish-packages.yml | 2 +- README.md | 193 +++--- apps/site/e2e/auth-docs.spec.ts | 3 + apps/site/e2e/home.spec.ts | 66 +- apps/site/next.config.ts | 4 +- apps/site/package.json | 6 +- apps/site/playwright.config.ts | 9 +- .../(app)/creator/manage/[courseId]/page.tsx | 168 +---- .../src/app/(app)/creator/manage/page.tsx | 194 +----- apps/site/src/app/(marketing)/docs/page.tsx | 109 +-- .../(marketing)/how-graspful-works/page.tsx | 75 +-- .../site/src/app/(marketing)/pricing/page.tsx | 56 +- apps/site/src/app/globals.css | 1 + apps/site/src/app/layout.tsx | 8 +- .../creator/__tests__/api-errors.test.ts | 21 + .../creator/__tests__/course-editor.test.tsx | 248 +++++++ .../src/components/creator/yaml-editor.tsx | 77 +-- .../site/__tests__/home-page.test.tsx | 42 +- apps/site/src/components/site/footer.tsx | 4 +- apps/site/src/components/site/header.tsx | 6 +- apps/site/src/components/site/home-page.tsx | 437 ++++-------- apps/site/src/lib/api-client.ts | 3 +- apps/site/src/lib/site-config.ts | 101 +-- apps/site/tsconfig.json | 4 +- apps/site/vitest.config.ts | 1 + apps/web/e2e/academy.spec.ts | 175 +++-- apps/web/e2e/agent-academy-cli-flow.spec.ts | 35 +- apps/web/e2e/agent-course-creation.spec.ts | 75 ++- apps/web/e2e/agent-discovery.spec.ts | 19 +- apps/web/e2e/agent-pipeline-e2e.spec.ts | 351 ++++------ apps/web/e2e/agents-page.spec.ts | 32 +- apps/web/e2e/assessment-integrity.spec.ts | 625 ++++++++++++++++++ apps/web/e2e/auth-recovery.spec.ts | 10 +- apps/web/e2e/course-import.spec.ts | 128 ++-- apps/web/e2e/courses.spec.ts | 52 +- apps/web/e2e/creator-authoring.spec.ts | 77 ++- apps/web/e2e/creator-billing.spec.ts | 10 +- apps/web/e2e/creator-manage-edit.spec.ts | 16 +- apps/web/e2e/creator-manage-new.spec.ts | 35 +- apps/web/e2e/diagnostic.spec.ts | 231 +++---- apps/web/e2e/docs-smoke.spec.ts | 31 +- apps/web/e2e/helpers/api-auth.ts | 3 +- apps/web/e2e/helpers/auth.ts | 13 +- apps/web/e2e/landing.spec.ts | 22 +- apps/web/e2e/learn-access.spec.ts | 269 ++++++-- apps/web/e2e/learner-happy-path.spec.ts | 65 +- apps/web/e2e/marketing-preview.spec.ts | 35 + apps/web/e2e/pricing.spec.ts | 49 +- apps/web/e2e/responsive.spec.ts | 2 +- apps/web/e2e/seo-smoke.spec.ts | 2 + apps/web/next.config.ts | 2 +- apps/web/package.json | 6 +- apps/web/playwright.config.ts | 19 +- .../components/billing-settings.test.tsx | 154 +++++ .../__tests__/components/lesson-flow.test.tsx | 92 ++- .../components/marketing/pricing.test.tsx | 89 +-- .../__tests__/components/quiz-flow.test.tsx | 94 ++- .../__tests__/components/review-flow.test.tsx | 39 +- .../components/section-exam-flow.test.tsx | 137 ++++ .../__tests__/components/study-pages.test.tsx | 103 +++ .../components/study-router.test.tsx | 35 +- .../(app)/academy/[academyId]/study/page.tsx | 5 +- .../(app)/creator/manage/[courseId]/page.tsx | 169 +---- .../web/src/app/(app)/creator/manage/page.tsx | 194 +----- .../src/app/(app)/study/[courseId]/page.tsx | 5 +- .../app/(app)/study/[courseId]/quiz/page.tsx | 9 +- .../[courseId]/review/[conceptId]/page.tsx | 9 +- .../sections/[sectionId]/exam/page.tsx | 9 +- apps/web/src/app/(marketing)/agents/page.tsx | 102 ++- .../(marketing)/ai-course-builder/page.tsx | 23 +- .../src/app/(marketing)/docs/billing/page.tsx | 282 +------- .../(marketing)/docs/brand-schema/page.tsx | 2 +- .../web/src/app/(marketing)/docs/cli/page.tsx | 10 +- .../docs/course-creation-guide/page.tsx | 31 +- .../(marketing)/docs/course-schema/page.tsx | 25 +- .../app/(marketing)/docs/glossary/page.tsx | 2 +- .../(marketing)/docs/how-it-works/page.tsx | 40 +- .../web/src/app/(marketing)/docs/mcp/page.tsx | 95 ++- apps/web/src/app/(marketing)/docs/page.tsx | 14 +- .../app/(marketing)/docs/quickstart/page.tsx | 324 ++++----- .../app/(marketing)/docs/review-gate/page.tsx | 335 ++-------- apps/web/src/app/(marketing)/page.tsx | 27 - apps/web/src/app/(marketing)/pricing/page.tsx | 6 +- apps/web/src/app/globals.css | 1 + apps/web/src/app/layout.tsx | 2 +- .../academies/[academySlug]/study/page.tsx | 23 +- .../courses/[courseSlug]/study/page.tsx | 19 +- .../courses/[courseSlug]/study/quiz/page.tsx | 13 +- .../study/review/[conceptId]/page.tsx | 13 +- .../study/sections/[sectionId]/exam/page.tsx | 11 +- .../app/llms-full.txt/__tests__/route.test.ts | 101 +++ apps/web/src/app/llms-full.txt/route.ts | 495 +++++--------- .../src/components/app/billing-settings.tsx | 139 ++-- apps/web/src/components/app/lesson-flow.tsx | 104 ++- apps/web/src/components/app/quiz-flow.tsx | 94 +-- apps/web/src/components/app/review-flow.tsx | 105 ++- .../src/components/app/section-exam-flow.tsx | 190 +++--- apps/web/src/components/app/study-router.tsx | 45 +- .../creator/__tests__/api-errors.test.ts | 22 + .../creator/__tests__/course-editor.test.tsx | 248 +++++++ .../src/components/creator/yaml-editor.tsx | 77 +-- .../marketing/__tests__/cta.test.tsx | 6 +- .../marketing/__tests__/hero.test.tsx | 33 +- .../landing-hero-experiment.test.tsx | 6 +- .../__tests__/lesson-preview.test.tsx | 26 + .../marketing/__tests__/nav.test.tsx | 3 +- .../__tests__/review-gate-docs.test.tsx | 18 + apps/web/src/components/marketing/cta.tsx | 15 +- apps/web/src/components/marketing/faq.tsx | 4 +- .../web/src/components/marketing/features.tsx | 12 +- apps/web/src/components/marketing/footer.tsx | 14 +- apps/web/src/components/marketing/hero.tsx | 52 +- .../src/components/marketing/how-it-works.tsx | 12 +- .../components/marketing/lesson-preview.tsx | 64 ++ apps/web/src/components/marketing/nav.tsx | 4 +- apps/web/src/components/marketing/pricing.tsx | 347 +--------- .../marketing/product-proof-hero.tsx | 156 +---- .../src/lib/__tests__/learn-server.test.ts | 29 + apps/web/src/lib/api-client.ts | 3 +- .../lib/brand/__tests__/resolve-db.test.ts | 73 +- apps/web/src/lib/brand/defaults.ts | 536 +++++++-------- apps/web/src/lib/brand/resolve-db.ts | 63 +- apps/web/src/lib/hooks/use-timer.ts | 51 +- apps/web/src/lib/learn-server.ts | 20 +- apps/web/vitest.config.ts | 1 + .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + .../__tests__/audit-published-courses.test.ts | 145 ++++ backend/scripts/audit-published-courses.ts | 246 +++++++ backend/scripts/import-course.ts | 227 +------ .../src/assessment/answer-evaluator.spec.ts | 14 + backend/src/assessment/answer-evaluator.ts | 22 +- .../assessment/assessment-scope.service.ts | 19 + .../assessment/assessment.controller.spec.ts | 36 +- .../src/assessment/assessment.controller.ts | 33 +- backend/src/assessment/assessment.module.ts | 3 +- .../src/assessment/dto/submit-answer.dto.ts | 8 +- .../dto/submit-review-answer.dto.ts | 2 +- .../problem-submission.service.spec.ts | 209 +++++- .../assessment/problem-submission.service.ts | 224 +++++-- backend/src/assessment/quiz.service.spec.ts | 524 ++++++++------- backend/src/assessment/quiz.service.ts | 458 +++++++------ backend/src/assessment/review.service.spec.ts | 453 +++++++------ backend/src/assessment/review.service.ts | 331 +++++++--- .../assessment/section-exam.service.spec.ts | 196 +++++- .../src/assessment/section-exam.service.ts | 368 ++++++----- .../src/auth/auth-register.controller.spec.ts | 1 + backend/src/auth/provision.service.spec.ts | 45 ++ backend/src/auth/provision.service.ts | 3 + backend/src/auth/registration.service.ts | 12 +- backend/src/billing/billing-configuration.ts | 41 ++ .../src/billing/billing.controller.spec.ts | 7 +- backend/src/billing/billing.controller.ts | 15 +- backend/src/billing/billing.service.spec.ts | 409 ++++++------ backend/src/billing/billing.service.ts | 134 ++-- backend/src/billing/connect.service.spec.ts | 83 +++ backend/src/billing/connect.service.ts | 27 +- .../billing/stripe-webhook.controller.spec.ts | 76 +++ .../src/billing/stripe-webhook.controller.ts | 14 +- backend/src/brands/brands.controller.spec.ts | 17 + backend/src/brands/brands.controller.ts | 6 + backend/src/brands/brands.service.spec.ts | 92 ++- backend/src/brands/brands.service.ts | 10 +- .../src/brands/dto/update-brand.dto.spec.ts | 26 + backend/src/brands/dto/update-brand.dto.ts | 13 + backend/src/config/env.validation.ts | 4 + .../diagnostic-session.workflow.ts | 15 + .../diagnostic-session.service.spec.ts | 82 +++ .../diagnostic-session.queries.spec.ts | 126 ++++ .../queries/diagnostic-session.queries.ts | 26 +- .../academy-gamification.controller.spec.ts | 1 + .../academy-gamification.controller.ts | 2 +- .../course-progress-read.service.spec.ts | 58 +- .../course-progress-read.service.ts | 36 +- .../gamification.controller.spec.ts | 1 + .../gamification/gamification.controller.ts | 2 +- backend/src/gamification/xp.service.spec.ts | 260 +++++++- backend/src/gamification/xp.service.ts | 127 +++- .../academy-graph.controller.spec.ts | 21 +- .../academy-graph.controller.ts | 20 +- .../academy-importer.service.ts | 10 +- .../course-management.service.spec.ts | 70 +- .../application/course-management.service.ts | 26 +- .../course-importer.service.spec.ts | 126 ++++ .../course-importer.service.ts | 67 +- .../course-read.service.spec.ts | 154 ++++- .../knowledge-graph/course-read.service.ts | 134 ++-- .../knowledge-graph.controller.spec.ts | 27 +- .../knowledge-graph.controller.ts | 21 +- .../knowledge-graph/review.service.spec.ts | 58 ++ backend/src/knowledge-graph/review.service.ts | 8 +- ...academy-learning-engine.controller.spec.ts | 35 + .../academy-learning-engine.controller.ts | 8 +- .../learning-engine.controller.spec.ts | 28 +- .../learning-engine.controller.ts | 6 +- .../learning-engine/lesson.service.spec.ts | 35 +- backend/src/learning-engine/lesson.service.ts | 4 +- .../remediation.service.spec.ts | 17 + .../learning-engine/remediation.service.ts | 20 +- .../application/posthog.service.spec.ts | 34 + .../src/shared/application/posthog.service.ts | 34 +- .../fire-update.service.spec.ts | 136 ++++ .../spaced-repetition/fire-update.service.ts | 46 +- .../academy-student-model.controller.spec.ts | 108 +++ .../academy-student-model.controller.ts | 3 + .../student-state.lifecycle.spec.ts | 126 ++++ .../application/student-state.lifecycle.ts | 34 +- .../student-model/enrollment.service.spec.ts | 125 +++- .../src/student-model/enrollment.service.ts | 20 +- .../queries/academy-progress.query.spec.ts | 68 ++ .../queries/academy-progress.query.ts | 5 +- .../queries/student-state.queries.ts | 85 ++- .../student-model.controller.spec.ts | 45 ++ .../student-model/student-model.controller.ts | 3 + .../student-state.service.spec.ts | 83 ++- .../student-model/student-state.service.ts | 75 ++- bun.lock | 43 +- content/brands/README.md | 28 +- content/brands/electrician.yaml | 86 +-- content/brands/firefighter.yaml | 80 +-- content/brands/graspful.yaml | 128 ++-- content/brands/javascript.yaml | 76 +-- content/brands/posthog.yaml | 74 +-- docs/billing-setup.md | 39 ++ docs/course-review-gate.md | 34 +- docs/local-e2e.md | 53 ++ docs/marketing/benchmarks.md | 155 +---- docs/marketing/comparison.md | 109 +-- docs/marketing/faq.md | 259 +------- docs/prelaunch-data-remediation.md | 152 +++++ docs/signup-alerting.md | 40 ++ packages/cli/README.md | 71 +- packages/cli/package.json | 2 +- .../commands/__tests__/brand-import.test.ts | 123 ++++ .../cli/src/commands/__tests__/init.test.ts | 162 +++++ .../commands/__tests__/publication.test.ts | 238 +++++++ packages/cli/src/commands/import.ts | 36 +- packages/cli/src/commands/init.ts | 71 +- packages/cli/src/commands/publish.ts | 17 +- packages/cli/src/index.ts | 2 +- packages/creator-ui/package.json | 23 + packages/creator-ui/src/api-errors.ts | 15 + packages/creator-ui/src/contracts.ts | 100 +++ packages/creator-ui/src/course-editor.tsx | 199 ++++++ packages/creator-ui/src/index.ts | 2 + packages/creator-ui/src/yaml-editor.tsx | 31 + packages/mcp/README.md | 115 ++-- packages/mcp/package.json | 2 +- .../mcp/src/__tests__/brand-import.test.ts | 95 +++ .../mcp/src/__tests__/publication.test.ts | 274 ++++++++ packages/mcp/src/index.ts | 71 +- packages/shared/package.json | 2 +- packages/shared/src/__tests__/domain.test.ts | 9 +- .../__tests__/publication-readiness.test.ts | 189 ++++++ packages/shared/src/index.ts | 1 + packages/shared/src/publication.ts | 27 + packages/shared/src/quality-gate.ts | 188 +++++- .../src/schemas/brand-settings.schema.ts | 35 + .../shared/src/schemas/course-yaml.schema.ts | 151 ++++- packages/shared/src/schemas/index.ts | 1 + railway.json | 3 +- scripts/e2e-env.test.ts | 58 ++ scripts/e2e-env.ts | 65 ++ supabase/config.toml | 1 + 265 files changed, 13566 insertions(+), 7475 deletions(-) create mode 100644 apps/site/src/components/creator/__tests__/api-errors.test.ts create mode 100644 apps/site/src/components/creator/__tests__/course-editor.test.tsx create mode 100644 apps/web/e2e/assessment-integrity.spec.ts create mode 100644 apps/web/e2e/marketing-preview.spec.ts create mode 100644 apps/web/src/__tests__/components/billing-settings.test.tsx create mode 100644 apps/web/src/__tests__/components/section-exam-flow.test.tsx create mode 100644 apps/web/src/__tests__/components/study-pages.test.tsx create mode 100644 apps/web/src/app/llms-full.txt/__tests__/route.test.ts create mode 100644 apps/web/src/components/creator/__tests__/api-errors.test.ts create mode 100644 apps/web/src/components/creator/__tests__/course-editor.test.tsx create mode 100644 apps/web/src/components/marketing/__tests__/lesson-preview.test.tsx create mode 100644 apps/web/src/components/marketing/__tests__/review-gate-docs.test.tsx create mode 100644 apps/web/src/components/marketing/lesson-preview.tsx create mode 100644 apps/web/src/lib/__tests__/learn-server.test.ts create mode 100644 backend/prisma/migrations/20260908173000_add_lesson_submission_receipt/migration.sql create mode 100644 backend/scripts/__tests__/audit-published-courses.test.ts create mode 100644 backend/scripts/audit-published-courses.ts create mode 100644 backend/src/assessment/assessment-scope.service.ts create mode 100644 backend/src/auth/provision.service.spec.ts create mode 100644 backend/src/billing/billing-configuration.ts create mode 100644 backend/src/billing/connect.service.spec.ts create mode 100644 backend/src/billing/stripe-webhook.controller.spec.ts create mode 100644 backend/src/brands/dto/update-brand.dto.spec.ts create mode 100644 backend/src/diagnostic/queries/diagnostic-session.queries.spec.ts create mode 100644 backend/src/learning-engine/academy-learning-engine.controller.spec.ts create mode 100644 backend/src/student-model/academy-student-model.controller.spec.ts create mode 100644 backend/src/student-model/application/student-state.lifecycle.spec.ts create mode 100644 backend/src/student-model/queries/academy-progress.query.spec.ts create mode 100644 docs/billing-setup.md create mode 100644 docs/local-e2e.md create mode 100644 docs/prelaunch-data-remediation.md create mode 100644 docs/signup-alerting.md create mode 100644 packages/cli/src/commands/__tests__/brand-import.test.ts create mode 100644 packages/cli/src/commands/__tests__/init.test.ts create mode 100644 packages/cli/src/commands/__tests__/publication.test.ts create mode 100644 packages/creator-ui/package.json create mode 100644 packages/creator-ui/src/api-errors.ts create mode 100644 packages/creator-ui/src/contracts.ts create mode 100644 packages/creator-ui/src/course-editor.tsx create mode 100644 packages/creator-ui/src/index.ts create mode 100644 packages/creator-ui/src/yaml-editor.tsx create mode 100644 packages/mcp/src/__tests__/brand-import.test.ts create mode 100644 packages/mcp/src/__tests__/publication.test.ts create mode 100644 packages/shared/src/__tests__/publication-readiness.test.ts create mode 100644 packages/shared/src/publication.ts create mode 100644 packages/shared/src/schemas/brand-settings.schema.ts create mode 100644 scripts/e2e-env.test.ts create mode 100644 scripts/e2e-env.ts diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 82081bd..5f7915b 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -14,6 +14,11 @@ jobs: unit-component-tests: name: Unit & Component Tests runs-on: ubuntu-latest + env: + POSTHOG_PERSONAL_API_KEY: "" + POSTHOG_PROJECT_ID: "" + POSTHOG_API_KEY: "" + NEXT_PUBLIC_POSTHOG_KEY: "" steps: - uses: actions/checkout@v5 @@ -22,7 +27,10 @@ jobs: bun-version: "1.3.6" - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile + + - name: Test E2E environment safety + run: bun test scripts/e2e-env.test.ts - name: Build shared package run: cd packages/shared && bun run build @@ -57,6 +65,9 @@ jobs: - name: Run backend tests run: cd backend && bun run test + - name: Test published-course remediation + run: bun test backend/scripts/__tests__/audit-published-courses.test.ts + - name: Run frontend tests run: cd apps/web && bun run test @@ -74,6 +85,11 @@ jobs: needs: unit-component-tests runs-on: ubuntu-latest timeout-minutes: 30 + env: + POSTHOG_PERSONAL_API_KEY: "" + POSTHOG_PROJECT_ID: "" + POSTHOG_API_KEY: "" + NEXT_PUBLIC_POSTHOG_KEY: "" steps: - uses: actions/checkout@v5 @@ -83,25 +99,51 @@ jobs: - uses: supabase/setup-cli@v1 with: - version: latest + version: 2.90.0 - - name: Start local Supabase + - name: Start isolated local Supabase run: | - # Move RLS migration aside — it references Prisma tables that don't exist yet - mv supabase/migrations/00002_rls_policies.sql /tmp/00002_rls_policies.sql - supabase start - # Will apply RLS after Prisma migrations - - - name: Extract Supabase keys + # Keep test auth settings and signing keys outside the deployment config. + supabase_dir="$RUNNER_TEMP/graspful-e2e-supabase" + mkdir -p "$supabase_dir" + supabase init --workdir "$supabase_dir" + SUPABASE_TEST_DIR="$supabase_dir" python3 - <<'PYCONFIG' + import os + from pathlib import Path + + config = Path(os.environ["SUPABASE_TEST_DIR"]) / "supabase/config.toml" + contents = config.read_text() + contents = contents.replace('site_url = "http://127.0.0.1:3000"', 'site_url = "http://localhost:3001"') + contents = contents.replace('additional_redirect_urls = ["https://127.0.0.1:3000"]', 'additional_redirect_urls = ["http://localhost:3001/**", "http://localhost:3002/**", "http://graspful.ai:3001/**", "http://app.graspful.ai:3001/**"]') + contents = contents.replace('# signing_keys_path = "./signing_keys.json"', 'signing_keys_path = "./signing_keys.json"') + assert 'signing_keys_path = "./signing_keys.json"' in contents + assert 'site_url = "http://localhost:3001"' in contents + assert 'http://localhost:3002/**' in contents + config.write_text(contents) + (config.parent / "signing_keys.json").write_text("[]") + PYCONFIG + chmod 600 "$supabase_dir/supabase/signing_keys.json" + supabase gen signing-key --workdir "$supabase_dir" >/dev/null 2>&1 + # Prisma owns the public schema, so apply SQL migrations after Prisma. + supabase start --workdir "$supabase_dir" -x realtime,storage-api,imgproxy,postgres-meta,studio,edge-runtime,logflare,vector,supavisor > "$RUNNER_TEMP/supabase-start.log" 2>&1 + + - name: Extract local Supabase keys id: supabase run: | - echo "SUPABASE_URL=$(supabase status --output json | jq -r '.API_URL')" >> "$GITHUB_OUTPUT" - echo "ANON_KEY=$(supabase status --output json | jq -r '.ANON_KEY')" >> "$GITHUB_OUTPUT" - echo "SERVICE_ROLE_KEY=$(supabase status --output json | jq -r '.SERVICE_ROLE_KEY')" >> "$GITHUB_OUTPUT" - echo "DB_URL=$(supabase status --output json | jq -r '.DB_URL')" >> "$GITHUB_OUTPUT" + supabase status --workdir "$RUNNER_TEMP/graspful-e2e-supabase" --output json > "$RUNNER_TEMP/supabase-status.json" + for key in ANON_KEY SERVICE_ROLE_KEY; do + value=$(jq -er ".$key" "$RUNNER_TEMP/supabase-status.json") + echo "::add-mask::$value" + echo "$key=$value" >> "$GITHUB_OUTPUT" + done + echo "SUPABASE_URL=$(jq -er '.API_URL' "$RUNNER_TEMP/supabase-status.json")" >> "$GITHUB_OUTPUT" + echo "DB_URL=$(jq -er '.DB_URL' "$RUNNER_TEMP/supabase-status.json")" >> "$GITHUB_OUTPUT" + # The backend verifies asymmetric JWTs through Supabase JWKS. + supabase_url=$(jq -er '.API_URL' "$RUNNER_TEMP/supabase-status.json") + curl --fail --silent "$supabase_url/auth/v1/.well-known/jwks.json" | jq -e '.keys | length > 0' - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Build shared package run: cd packages/shared && bun run build @@ -115,8 +157,13 @@ jobs: DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }} - - name: Apply RLS policies - run: psql "${{ steps.supabase.outputs.DB_URL }}" -f /tmp/00002_rls_policies.sql + - name: Apply auth triggers and RLS policies + run: | + for migration in supabase/migrations/*.sql; do + psql "$DATABASE_URL" --set ON_ERROR_STOP=1 --file "$migration" + done + env: + DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} - name: Seed database run: cd backend && bun x prisma db seed @@ -140,44 +187,71 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.ANON_KEY }} NEXT_PUBLIC_BACKEND_URL: http://localhost:3000/api/v1 + - name: Build site + run: cd apps/site && bun run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.ANON_KEY }} + NEXT_PUBLIC_BACKEND_URL: http://localhost:3000/api/v1 + - name: Install Playwright browsers run: cd apps/web && bun x playwright install --with-deps chromium - - name: Start services and run E2E tests + - name: Start services and run both E2E suites run: | - # Start backend (subshell so cd doesn't affect parent) - (cd backend && bun run start:prod) & - - # Start frontend on port 3001 - (cd apps/web && PORT=3001 bun run start) & - - # Wait for both - timeout 60 bash -c 'until curl -sf http://localhost:3000/api/v1/health 2>/dev/null; do sleep 2; done' - echo "Backend ready" - timeout 120 bash -c 'until curl -sf http://localhost:3001 2>/dev/null; do sleep 2; done' - echo "Frontend ready" - - # Run Playwright - cd apps/web && bun run test:e2e + # Migrations ran above. Start the compiled API without start:prod's migration side effect. + (cd backend && TS_NODE_PROJECT=tsconfig.runtime.json node -r tsconfig-paths/register dist/main.js) > "$RUNNER_TEMP/backend-e2e.log" 2>&1 & + backend_pid=$! + (cd apps/web && NODE_ENV=production PORT=3001 bun run start) > "$RUNNER_TEMP/web-e2e.log" 2>&1 & + web_pid=$! + (cd apps/site && NODE_ENV=production bun run start) > "$RUNNER_TEMP/site-e2e.log" 2>&1 & + site_pid=$! + trap 'kill "$backend_pid" "$web_pid" "$site_pid" 2>/dev/null || true' EXIT + + timeout 60 bash -c 'until curl -sf http://localhost:3000/api/v1/health >/dev/null; do sleep 2; done' + timeout 120 bash -c 'until curl -sf http://localhost:3001 >/dev/null; do sleep 2; done' + timeout 120 bash -c 'until curl -sf http://localhost:3002 >/dev/null; do sleep 2; done' + + # Run both suites even when the first fails, so both reports are available. + web_status=0 + (cd apps/web && bun run test:e2e) || web_status=$? + site_status=0 + (cd apps/site && bun run test:e2e) || site_status=$? + if [ "$web_status" -ne 0 ] || [ "$site_status" -ne 0 ]; then + exit 1 + fi env: CI: "true" + E2E_REUSE_EXISTING_SERVER: "1" + NODE_ENV: development DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }} SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }} SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }} - ALLOWED_ORIGINS: http://localhost:3001 + APP_URL: http://localhost:3001 + ALLOWED_ORIGINS: http://localhost:3001,http://localhost:3002,http://graspful.ai:3001,http://app.graspful.ai:3001 NEXT_PUBLIC_SUPABASE_URL: ${{ steps.supabase.outputs.SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.ANON_KEY }} NEXT_PUBLIC_BACKEND_URL: http://localhost:3000/api/v1 - - name: Upload Playwright report + - name: Upload Playwright reports uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: - name: playwright-report - path: apps/web/playwright-report/ + name: playwright-reports + path: | + apps/web/playwright-report/ + apps/site/playwright-report/ retention-days: 14 + - name: Upload service logs on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-service-logs + path: ${{ runner.temp }}/*-e2e.log + retention-days: 7 + deploy-backend: name: Deploy Backend needs: @@ -188,11 +262,12 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Railway CLI - run: npm install -g @railway/cli + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.6" - name: Deploy backend to Railway - run: railway up --service ${{ vars.RAILWAY_SERVICE_NAME }} + run: bun x @railway/cli up --service ${{ vars.RAILWAY_SERVICE_NAME }} env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index ac79573..8839b1c 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -51,7 +51,7 @@ jobs: - name: Install npm with trusted publishing support run: npm install --global npm@11.8.0 - - run: bun install + - run: bun install --frozen-lockfile - name: Determine target id: target diff --git a/README.md b/README.md index e3f2305..290e359 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,70 @@ # Graspful -> Create adaptive learning academies and courses with AI agents. Launch in minutes. +Build courses with lessons, practice questions, and scheduled review. -## What Is Graspful? +Graspful stores academies, courses, and brand configuration in YAML files. You or an external agent, such as Claude or Codex, author the content through CLI commands or MCP tools. The CLI creates draft scaffolds. The author supplies the source material, writes the lessons and questions, and reviews the facts before publication. -Graspful is an agent-first academy creation platform. Academies and courses are defined as YAML files with knowledge graphs, validated by schema, and imported via CLI or MCP server -- no UI clicking required. Agents (or humans) scaffold an academy, break it into prerequisite-aware courses, fill in content concept by concept, run quality checks, and publish. The platform handles the rest: adaptive diagnostics, mastery-based progression, spaced repetition, white-label landing pages, and Stripe billing. +Learners work through explanations, examples, and questions. Their answers update progress estimates. Prerequisites guide lesson order, and the system schedules later review. -## How It Works +## How it works -1. **Plan academy** -- `graspful create academy --topic "CKA Exam"` generates the academy layers, course map, and authoring gates -2. **Scaffold courses** -- `graspful create course --topic "Cluster Networking"` generates each course graph -3. **Fill** -- `graspful fill concept course.yaml networking` adds KPs and practice problems -4. **Review** -- `graspful review course.yaml` runs 10 quality checks, including whether problems only assess taught material -5. **Import** -- `graspful import academy.yaml --org k8s-cert --course-dir . --publish` goes live -6. **Brand** -- `graspful create brand --niche "Kubernetes" --topic "CKA Exam"` generates the landing page that actually sells the academy +1. **Plan the academy.** Define its audience, source material, course scope, and prerequisites. +2. **Create draft files.** Use the CLI or MCP tools to scaffold the academy and its courses. Scaffold and fill commands generate placeholders for the author to complete. +3. **Write and review.** Replace all placeholders with sourced explanations, worked examples, and practice questions. Check the answer explanations against the sources. +4. **Validate.** Run `graspful validate` and `graspful review`. Automated checks report structural problems. The author still checks accuracy and teaching quality. +5. **Import a draft.** Import the reviewed files, inspect the result, then publish when ready. Importing without `--publish` saves a draft. +6. **Configure the brand.** Import a brand YAML file for the landing page, theme, and copy. Custom domains require domain and hosting setup. -Three YAMLs (academy + courses + brand) produce one live product with adaptive learning, spaced repetition, and Stripe billing. +Read [the course authoring runbook](docs/adding-a-course.md) before building a course. -## Quick Start +## Quickstart ```bash -npx @graspful/cli init -# opens browser auth, then saves an API key locally -graspful register --email you@example.com -graspful create academy --topic "Your Topic" -o academy.yaml +bun add -g @graspful/cli + +# Create a local draft scaffold. +bunx @graspful/cli create academy --topic "Your topic" -o academy.yaml mkdir -p courses -graspful create course --topic "Foundations" -o courses/foundations.yaml +bunx @graspful/cli create course --topic "Foundations" -o courses/foundations.yaml ``` -## Tech Stack - -- **Backend:** NestJS, Prisma, PostgreSQL (Supabase-hosted) -- **Frontend:** Next.js (App Router), React, Tailwind CSS, shadcn/ui -- **CLI:** `@graspful/cli` (commander.js) -- **MCP Server:** `@graspful/mcp` for AI agent integration -- **Auth:** Supabase Auth (JWT) + API keys for agents -- **Billing:** Stripe + Stripe Connect (70/30 revenue share) -- **Monorepo:** Turborepo, bun +These files need authoring and review before import. Follow the [runbook](docs/adding-a-course.md) to complete the content and match the academy manifest to its course files. -## Architecture +Register before using import or publish commands: -``` -graspful/ -├── apps/web/ # Next.js frontend -├── backend/ # NestJS API -├── packages/ -│ ├── shared/ # Zod schemas, types, quality gate -│ ├── cli/ # @graspful/cli -│ └── mcp/ # @graspful/mcp server -├── content/ -│ ├── courses/ # Course YAML files -│ ├── brands/ # Brand YAML files -│ └── academies/ # Multi-course academy manifests -└── docs/ # Documentation +```bash +bunx @graspful/cli register --email you@example.com ``` -## CLI Commands +Registration opens browser authentication and saves an API key locally. If your MCP client does not reuse the saved CLI credentials, restart its Graspful server with `GRASPFUL_API_KEY` set. -| Command | Auth? | Description | -|---------|:---:|-------------| -| `graspful register` | No | Create account + API key via browser auth | -| `graspful login` | No | Authenticate with existing credentials | -| `graspful create academy` | No | Generate academy plan and manifest skeleton | -| `graspful create course` | No | Generate course YAML skeleton | -| `graspful create brand` | No | Generate brand YAML with theme presets | -| `graspful fill concept` | No | Add KPs and problems to a concept | -| `graspful validate` | No | Offline schema + DAG validation | -| `graspful review` | No | 10 mechanical quality checks, including teaching alignment | -| `graspful describe` | No | Course statistics | -| `graspful import` | **Yes** | Push YAML to Graspful instance | -| `graspful publish` | **Yes** | Publish a draft course | +## CLI commands -Run `graspful register [--email ]` before `import` or `publish`. The browser flow mints an API key and saves it to `~/.graspful/credentials.json`. For MCP, restart the server with `GRASPFUL_API_KEY` set if your client does not reuse the saved CLI credentials automatically. +| Command | Auth required | Purpose | +|---------|:-------------:|---------| +| `graspful register` | No | Register through browser authentication and save an API key | +| `graspful login` | No | Authenticate an existing account | +| `graspful create academy` | No | Create an academy plan and manifest scaffold | +| `graspful create course` | No | Create a course YAML scaffold | +| `graspful create brand` | No | Create brand YAML with theme presets | +| `graspful fill concept` | No | Add placeholder knowledge points and questions | +| `graspful validate` | No | Validate the YAML schema and prerequisite graph | +| `graspful review` | No | Run mechanical quality checks | +| `graspful describe` | No | Show course statistics | +| `graspful import` | Yes | Import YAML, saving a draft unless publication is requested and succeeds | +| `graspful publish` | Yes | Request publication of a reviewed draft | -## MCP Server +Check the command result to confirm whether publication succeeded. -For AI agent integration (Claude Code, Cursor, Codex): +## MCP server -```bash -npx @graspful/cli init # Auto-configures MCP for your editor -``` - -Or manually add to your MCP config: +`graspful init` authenticates through the browser and configures detected clients. For local authoring before registration, configure the MCP server directly. This example uses the Claude Desktop and Cursor format; see the linked MCP guide for other clients: ```json { "mcpServers": { "graspful": { - "command": "npx", + "command": "bunx", "args": ["@graspful/mcp"], "env": { "GRASPFUL_API_KEY": "gsk_..." } } @@ -95,70 +72,60 @@ Or manually add to your MCP config: } ``` -### MCP Tools - -| Tool | Auth? | Description | -|------|:---:|-------------| -| `graspful_scaffold_course` | No | Generate course YAML skeleton | -| `graspful_create_academy` | No | Generate academy plan and manifest scaffold | -| `graspful_fill_concept` | No | Add KPs and problems to a concept | -| `graspful_validate` | No | Validate YAML against schema | -| `graspful_review_course` | No | Run 10 quality checks, including teaching alignment | -| `graspful_describe_course` | No | Course statistics | -| `graspful_create_brand` | No | Generate brand YAML | -| `graspful_import_course` | **Yes** | Import course to platform | -| `graspful_import_academy` | **Yes** | Import academy manifest plus course YAMLs | -| `graspful_publish_course` | **Yes** | Publish a draft course | -| `graspful_import_brand` | **Yes** | Import brand config | -| `graspful_list_courses` | **Yes** | List org courses | - -Auth-gated tools return a prescriptive error if unauthenticated, telling the agent to run `graspful register` or set `GRASPFUL_API_KEY` before starting MCP. +The external agent writes the course content and uses Graspful tools to scaffold, validate, review, import, and publish it. The MCP server performs structured course operations. See the [MCP documentation](https://graspful.ai/docs/mcp) for setup and the tool reference. -## Development +## Billing status -```bash -# Install dependencies -bun install +Stripe and Stripe Connect integration code exists. Paid subscriptions and creator payouts are not ready for customers. Live credentials, price configuration, creator onboarding, webhook setup, and payment verification are required before accepting payments. -# Start development servers -bun run dev +The repository contains proposed pricing and revenue-share settings. Treat those as configuration under development, rather than an offer to customers. -# Backend (port 3000) -cd backend && bun run dev +## Tech stack -# Frontend (port 3001) -cd apps/web && bun run dev +- **Backend:** NestJS, Prisma, PostgreSQL +- **Frontend:** Next.js App Router, React, Tailwind CSS, shadcn/ui +- **CLI:** `@graspful/cli` +- **MCP server:** `@graspful/mcp` +- **Authentication:** Supabase Auth and API keys +- **Billing integration:** Stripe and Stripe Connect, pending setup +- **Workspace:** Turborepo and bun -# Build -bun run build +## Repository layout -# Test -bun run test - -# E2E tests -cd apps/web && npx playwright test +```text +graspful/ +├── apps/web/ # Next.js frontend +├── backend/ # NestJS API +├── packages/ +│ ├── shared/ # Schemas, types, quality checks +│ ├── cli/ # CLI +│ └── mcp/ # MCP server +├── content/ +│ ├── courses/ # Course YAML files +│ ├── brands/ # Brand YAML files +│ └── academies/ # Academy manifests +└── docs/ # Documentation ``` -## Revenue Model +## Development -70/30 revenue share (Apple model): +```bash +bun install +bun run dev +bun run build +bun run test +``` -- Free to create and publish courses -- When learners subscribe, Graspful collects payment -- Graspful keeps 30%, creator receives 70% via Stripe Connect -- No upfront cost, no monthly platform fee +Run the frontend browser tests from `apps/web` with `bun run test:e2e`. See [AGENTS.md](AGENTS.md) for service setup and test requirements. ## Documentation -- [Adding a Course](docs/adding-a-course.md) -- Step-by-step course creation guide -- [Course Review Gate](docs/course-review-gate.md) -- Quality checks and review specification -- [CLI Agent Strategy](docs/cli-agent-strategy.md) -- Full platform strategy and architecture -- [Content Guide](content/README.md) -- YAML schema reference and authoring guidelines +- [Course authoring runbook](docs/adding-a-course.md) +- [Course review gate](docs/course-review-gate.md) +- [Course content format](content/README.md) +- [Brand configuration](content/brands/README.md) +- [Platform FAQ](docs/marketing/faq.md) ## License -[O'Saasy License](LICENSE.md) -- MIT with one restriction: you can't offer this as a competing SaaS. - -**Why this license?** The adaptive learning science behind Graspful (BKT, spaced repetition, knowledge graphs) is built on open academic work. It should stay open. You can view the code, self-host it, learn from it, contribute to it. The one thing you can't do is take it and run a competing hosted service. That feels fair -- I'm trying to bring all these ideas together into something useful, and I'd like to get rewarded for that work. - -If you think there's a better way to do this, [open an issue](https://github.com/willwearing/graspful/issues). I'm genuinely interested in getting the balance right. +See [LICENSE.md](LICENSE.md) for the license terms. diff --git a/apps/site/e2e/auth-docs.spec.ts b/apps/site/e2e/auth-docs.spec.ts index f5d67cf..05c6495 100644 --- a/apps/site/e2e/auth-docs.spec.ts +++ b/apps/site/e2e/auth-docs.spec.ts @@ -51,5 +51,8 @@ test.describe("Auth and docs", () => { await expect( page.getByRole("heading", { name: "Reference", exact: true }), ).toBeVisible(); + await expect(page.getByText(/An import without --publish saves a draft for review/)).toBeVisible(); + await expect(page.getByText(/Confirm published: true in the response/)).toBeVisible(); + await expect(page.getByText(/Paid subscriptions are not available yet/)).toBeVisible(); }); }); diff --git a/apps/site/e2e/home.spec.ts b/apps/site/e2e/home.spec.ts index 9e99814..fe900bc 100644 --- a/apps/site/e2e/home.spec.ts +++ b/apps/site/e2e/home.spec.ts @@ -1,45 +1,57 @@ import { expect, test } from "@playwright/test"; test.describe("Graspful site", () => { - test("renders the homepage hero and key navigation", async ({ page }) => { - await page.goto("/"); - - // Hero headline (word-by-word spans) - const h1 = page.getByRole("heading", { level: 1 }); - await expect(h1).toBeVisible(); - await expect(h1).toContainText("Build courses where students actually learn"); - - await expect( - page.getByText("Pricing").first(), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: /what we do for you/i }), - ).toBeVisible(); + test("renders the course workflow and a labeled lesson example", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.status()).toBe(200); + + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Turn your source material into lessons and practice."); + await expect(page.getByRole("heading", { name: "From course files to learner practice" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Author, review, then publish" })).toBeVisible(); + await expect(page.getByText(/Importing without --publish saves a draft/)).toBeVisible(); + await expect(page.getByText(/Confirm that the response contains published: true/)).toBeVisible(); + await expect(page.getByRole("complementary", { name: "Illustrative lesson" })).toBeVisible(); + + await page.getByRole("button", { name: "2/9" }).click(); + await expect(page.getByText(/Convert 1\/3 to 2\/6 first/)).toBeVisible(); + await page.getByRole("button", { name: "1/2" }).click(); + await expect(page.getByText(/Correct\. 1\/3 equals 2\/6/)).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("home-desktop.png"), fullPage: true }); }); test("hero CTA routes to sign-up", async ({ page }) => { await page.goto("/"); - - await expect( - page.getByRole("link", { name: /create free account/i }).first(), - ).toHaveAttribute("href", /sign-up/); + await expect(page.locator("main").getByRole("link", { name: "Create free account", exact: true })).toHaveAttribute("href", "/sign-up"); }); - test("navigates to pricing from homepage", async ({ page }) => { + test("pricing describes unavailable paid subscriptions", async ({ page }) => { await page.goto("/"); - await page.getByRole("link", { name: /^pricing$/i }).first().click(); await expect(page).toHaveURL(/\/pricing/); - await expect( - page.getByRole("heading", { name: /pricing/i }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Pricing", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Paid subscriptions are not available yet" })).toBeVisible(); + await expect(page.locator("main").getByRole("link", { name: "Create free account", exact: true })).toHaveAttribute("href", "/sign-up"); }); - test("theme toggle is present", async ({ page }) => { + test("how it works explains learner estimates and review", async ({ page }) => { + const response = await page.goto("/how-graspful-works"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { name: "How Graspful works" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Return for review" })).toBeVisible(); + await expect(page.getByText(/Learner progress is an estimate/)).toBeVisible(); + }); + + test("mobile home fits the viewport", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + const width = await page.evaluate(() => ({ content: document.documentElement.scrollWidth, viewport: window.innerWidth })); + expect(width.content).toBeLessThanOrEqual(width.viewport); + await page.screenshot({ path: test.info().outputPath("home-mobile.png"), fullPage: true }); + }); - await expect( - page.getByRole("button", { name: /switch to/i }), - ).toBeVisible(); + test("theme toggle is present", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("button", { name: /switch to/i })).toBeVisible(); }); }); diff --git a/apps/site/next.config.ts b/apps/site/next.config.ts index cb651cd..8aa61f2 100644 --- a/apps/site/next.config.ts +++ b/apps/site/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + transpilePackages: ["@graspful/shared", "@graspful/creator-ui"], +}; export default nextConfig; diff --git a/apps/site/package.json b/apps/site/package.json index 603501a..ffb124a 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -22,7 +22,8 @@ "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "@graspful/creator-ui": "workspace:*" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -38,6 +39,7 @@ "jsdom": "^28.1.0", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "yaml": "^2.8.2" } } diff --git a/apps/site/playwright.config.ts b/apps/site/playwright.config.ts index b0977b4..46e1cb5 100644 --- a/apps/site/playwright.config.ts +++ b/apps/site/playwright.config.ts @@ -1,4 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; +import { getE2eEnvironment } from "../../scripts/e2e-env"; + +const testEnv = getE2eEnvironment(process.env); +Object.assign(process.env, testEnv); export default defineConfig({ testDir: "./e2e", @@ -19,8 +23,9 @@ export default defineConfig({ ], webServer: { command: "bun run dev", + env: testEnv, url: "http://localhost:3002", - reuseExistingServer: true, - timeout: 30_000, + reuseExistingServer: process.env.E2E_REUSE_EXISTING_SERVER === "1", + timeout: 120_000, }, }); diff --git a/apps/site/src/app/(app)/creator/manage/[courseId]/page.tsx b/apps/site/src/app/(app)/creator/manage/[courseId]/page.tsx index 6125a2c..358c2cb 100644 --- a/apps/site/src/app/(app)/creator/manage/[courseId]/page.tsx +++ b/apps/site/src/app/(app)/creator/manage/[courseId]/page.tsx @@ -1,162 +1,28 @@ "use client"; -import { useState, useEffect } from "react"; -import { useParams } from "next/navigation"; -import { Download, Save, Bot, Loader2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { CreatorCourseEditor } from "@graspful/creator-ui"; import { useCreatorOrg } from "@/lib/contexts/creator-org-context"; import { createSupabaseBrowserClient } from "@/lib/supabase/client"; import { apiClientFetch } from "@/lib/api-client"; -import { YamlEditor } from "@/components/creator/yaml-editor"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; + +async function getAccessToken() { + const { data } = await createSupabaseBrowserClient().auth.getSession(); + return data.session?.access_token ?? ""; +} export default function EditCoursePage() { const { orgSlug } = useCreatorOrg(); - const params = useParams<{ courseId: string }>(); - const courseId = params.courseId; - - const [brandConfig, setBrandConfig] = useState(""); - const [courseContent, setCourseContent] = useState(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [token, setToken] = useState(""); - - useEffect(() => { - async function loadCourse() { - const supabase = createSupabaseBrowserClient(); - const { data } = await supabase.auth.getSession(); - const accessToken = data.session?.access_token ?? ""; - setToken(accessToken); - - try { - const yaml = await apiClientFetch<{ yaml: string }>( - `/orgs/${orgSlug}/courses/${courseId}/yaml`, - accessToken - ); - setCourseContent(yaml.yaml); - setBrandConfig(""); // Brand config loaded separately in a future enhancement - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load course"); - } finally { - setLoading(false); - } - } - loadCourse(); - }, [orgSlug, courseId]); - - async function handleSave() { - setSaving(true); - setError(null); - setSuccess(false); - try { - await apiClientFetch( - `/orgs/${orgSlug}/courses/import`, - token, - { - method: "POST", - body: JSON.stringify({ - yaml: courseContent, - }), - } - ); - setSuccess(true); - setTimeout(() => setSuccess(false), 3000); - } catch (err) { - setError(err instanceof Error ? err.message : "Save failed"); - } finally { - setSaving(false); - } - } - - function handleDownload() { - const blob = new Blob( - [ - `# === Brand Config ===\n${brandConfig}\n\n# === Course Content ===\n${courseContent}`, - ], - { type: "text/yaml" } - ); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `course-${courseId}.yaml`; - a.click(); - URL.revokeObjectURL(url); - } - - if (loading) { - return ( -
- - - -
- ); - } - + const router = useRouter(); + const { courseId } = useParams<{ courseId: string }>(); return ( -
-
-

Edit Course

-

- Modify the brand config or course content, then save changes. -

-
- - - - {error && ( -
- {error} -
- )} - - {success && ( -
- Changes saved successfully. -
- )} - -
- - -
- - {/* Agent callout */} - - -
- -
-
-

Prefer using AI?

-

- Use the Graspful CLI or MCP server to edit courses with Claude, - Codex, Cursor, or any AI agent. -

-
-{`npx @graspful/cli import course.yaml --course-id ${courseId}`}
-            
-
-
-
-
+ router.push(`/creator/manage/${id}`)} + /> ); } diff --git a/apps/site/src/app/(app)/creator/manage/page.tsx b/apps/site/src/app/(app)/creator/manage/page.tsx index b05d562..5cd63dd 100644 --- a/apps/site/src/app/(app)/creator/manage/page.tsx +++ b/apps/site/src/app/(app)/creator/manage/page.tsx @@ -1,196 +1,26 @@ "use client"; -import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Download, Upload, Bot } from "lucide-react"; +import { CreatorCourseEditor } from "@graspful/creator-ui"; import { useCreatorOrg } from "@/lib/contexts/creator-org-context"; import { createSupabaseBrowserClient } from "@/lib/supabase/client"; import { apiClientFetch } from "@/lib/api-client"; -import { YamlEditor } from "@/components/creator/yaml-editor"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -const BRAND_CONFIG_TEMPLATE = `# Brand Configuration -# See docs: https://graspful.ai/docs/brand-schema - -name: "My Course Brand" -slug: "my-course-brand" # URL-safe, unique -tagline: "Learn X — the smart way" - -# Domain (assigned after publish) -# domain: "my-course-brand.graspful.ai" - -# Theme colors (HSL values) -theme: - primary: "217 91% 60%" - secondary: "45 93% 47%" - accent: "217 91% 95%" - background: "0 0% 100%" - foreground: "222 47% 11%" - radius: "0.5rem" - -# Landing page -landing: - hero: - headline: "TODO: Your headline here" - subheadline: "TODO: Describe what learners will master" - ctaText: "Start Learning Free" - -# Pricing (70/30 revenue share) -pricing: - monthly: 14.99 - yearly: 149 - currency: "USD" - trialDays: 7 - -# SEO -seo: - title: "TODO: Page title" - description: "TODO: Meta description" - keywords: - - "TODO" -`; - -const COURSE_CONTENT_TEMPLATE = `# Course Content -# See docs: https://graspful.ai/docs/course-schema - -name: "My Course" -slug: "my-course" -description: "TODO: What this course teaches" - -sections: - - name: "Getting Started" - slug: "getting-started" - order: 1 - concepts: - - name: "Introduction" - slug: "introduction" - order: 1 - explanation: "TODO: Explain this concept clearly" - knowledgePoints: - - id: "kp-intro-1" - statement: "TODO: A specific fact the learner should know" - problems: - - type: "multiple_choice" - stem: "TODO: Write a question" - options: - - text: "Correct answer" - correct: true - - text: "Wrong answer 1" - correct: false - - text: "Wrong answer 2" - correct: false - - text: "Wrong answer 3" - correct: false - knowledgePointIds: - - "kp-intro-1" -`; +async function getAccessToken() { + const { data } = await createSupabaseBrowserClient().auth.getSession(); + return data.session?.access_token ?? ""; +} export default function NewCoursePage() { const { orgSlug } = useCreatorOrg(); const router = useRouter(); - const [brandConfig, setBrandConfig] = useState(BRAND_CONFIG_TEMPLATE); - const [courseContent, setCourseContent] = useState(COURSE_CONTENT_TEMPLATE); - const [importing, setImporting] = useState(false); - const [error, setError] = useState(null); - const [token, setToken] = useState(""); - - useEffect(() => { - async function getToken() { - const supabase = createSupabaseBrowserClient(); - const { data } = await supabase.auth.getSession(); - setToken(data.session?.access_token ?? ""); - } - getToken(); - }, []); - - async function handleImport() { - setImporting(true); - setError(null); - try { - const result = await apiClientFetch<{ courseId: string }>( - `/orgs/${orgSlug}/courses/import`, - token, - { - method: "POST", - body: JSON.stringify({ yaml: courseContent }), - } - ); - router.push(`/creator/manage/${result.courseId}`); - } catch (err) { - setError(err instanceof Error ? err.message : "Import failed"); - } finally { - setImporting(false); - } - } - - function handleDownload() { - const blob = new Blob( - [ - `# === Brand Config ===\n${brandConfig}\n\n# === Course Content ===\n${courseContent}`, - ], - { type: "text/yaml" } - ); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "course.yaml"; - a.click(); - URL.revokeObjectURL(url); - } - return ( -
-
-

New Course

-

- Define your brand and course content as YAML, then import to the platform. -

-
- - - - {error && ( -
- {error} -
- )} - -
- - -
- - {/* Agent callout */} - - -
- -
-
-

Prefer using AI?

-

- Use the Graspful CLI or MCP server to create courses with Claude, - Codex, Cursor, or any AI agent. Agents generate the YAML for you. -

-
-{`npx @graspful/cli init
-npx @graspful/cli import course.yaml`}
-            
-
-
-
-
+ router.push(`/creator/manage/${id}`)} + /> ); } diff --git a/apps/site/src/app/(marketing)/docs/page.tsx b/apps/site/src/app/(marketing)/docs/page.tsx index b93fa2f..79e0f40 100644 --- a/apps/site/src/app/(marketing)/docs/page.tsx +++ b/apps/site/src/app/(marketing)/docs/page.tsx @@ -1,89 +1,52 @@ import Link from "next/link"; -const sections = [ - { - title: "Authoring", - items: [ - "Define course structure with concepts, sections, and dependencies.", - "Review generated content before it reaches learners.", - "Publish changes without rebuilding your own delivery platform.", - ], - }, - { - title: "Operations", - items: [ - "Manage brands, API keys, and learner billing from the creator app.", - "Use one platform runtime for learner delivery and revenue operations.", - "Keep the flagship site separate from the white-label learner surfaces.", - ], - }, +const steps = [ + { title: "Install", description: "Run npx @graspful/cli init to configure CLI and MCP tools for your coding agent." }, + { title: "Author", description: "Give Claude Code, Codex, or another MCP agent reliable source material and learning goals. Have it write lessons, worked examples, questions, and prerequisites in course YAML." }, + { title: "Review", description: "Run graspful validate course.yaml and graspful review course.yaml. Correct failures and inspect the content and answer keys yourself. A scaffold contains structure that still needs authored content." }, + { title: "Import a draft", description: "Run graspful register to sign in and save CLI credentials. Then run graspful import course.yaml --org your-org. An import without --publish saves a draft for review." }, + { title: "Publish", description: "After reviewing the draft, run graspful publish --org your-org --format json. Confirm published: true in the response before you share the course." }, ]; const docLinks = [ - { title: "CLI Reference", href: "https://graspful.ai/docs/cli", description: "Install, authenticate, and run course commands from your terminal." }, - { title: "Course Schema", href: "https://graspful.ai/docs/course-schema", description: "YAML structure for courses, concepts, knowledge points, and problems." }, - { title: "Brand Schema", href: "https://graspful.ai/docs/brand-schema", description: "Configure your academy theme, domain, and landing page." }, - { title: "Glossary", href: "https://graspful.ai/docs/glossary", description: "Key terms: concepts, knowledge points, mastery, diagnostics, and more." }, + { title: "CLI reference", href: "https://graspful.ai/docs/cli", description: "Install, authenticate, and run course commands from your terminal." }, + { title: "Course schema", href: "https://graspful.ai/docs/course-schema", description: "YAML structure for courses, concepts, knowledge points, and problems." }, + { title: "Brand schema", href: "https://graspful.ai/docs/brand-schema", description: "Configure your academy theme and landing page." }, + { title: "Glossary", href: "https://graspful.ai/docs/glossary", description: "Concepts, knowledge points, mastery estimates, and diagnostics." }, ]; export default function DocsPage() { return (
-
-
-

- Documentation -

-

- Getting started -

-

- Everything you need to build and publish adaptive courses with - Graspful. -

+
+
+

Getting started

+

Author courses with your coding agent and the Graspful CLI or MCP tools. Use the creator app to inspect drafts before you publish.

-
-
-
- {sections.map((section) => ( -
-

- {section.title} -

-
    - {section.items.map((item) => ( -
  • - - {item} -
  • - ))} -
+
+

Authoring

+
    + {steps.map((step, i) => ( +
  1. + +
    +

    {step.title}

    +

    {step.description}

    - ))} -
-

- Reference -

-
- {docLinks.map((doc) => ( - -

- {doc.title} → -

-

- {doc.description} -

- - ))} -
+ + ))} + +

Operations

+

Keep the YAML source in your own repository. Make course and brand changes through the CLI or MCP tools, then review the imported result. Paid subscriptions are not available yet.

+

Reference

+
+ {docLinks.map((doc) => ( + +

{doc.title}

+

{doc.description}

+ + ))}
diff --git a/apps/site/src/app/(marketing)/how-graspful-works/page.tsx b/apps/site/src/app/(marketing)/how-graspful-works/page.tsx index 4cb7218..1558555 100644 --- a/apps/site/src/app/(marketing)/how-graspful-works/page.tsx +++ b/apps/site/src/app/(marketing)/how-graspful-works/page.tsx @@ -1,68 +1,47 @@ +import Link from "next/link"; + const steps = [ { - number: "1", - title: "Find out what they already know", - description: - "Every course starts with a diagnostic. The platform figures out what the learner can prove they understand and skips the rest. No one sits through material they've already mastered.", + title: "Estimate the starting point", + description: "A diagnostic asks questions from the course and uses answers to estimate a learner's knowledge. These estimates guide which concepts to practice.", }, { - number: "2", - title: "Teach what actually matters next", - description: - "The sequence changes based on gaps. If a learner is weak on prerequisites, the platform handles that first. If they're strong, it moves them forward. The path is different for everyone.", + title: "Choose a concept to practice", + description: "The learning engine uses the prerequisite graph and the learner's current estimates to select work. When a learner struggles, prerequisite concepts can return for practice.", }, { - number: "3", - title: "Prove mastery before moving on", - description: - "Progress is gated on evidence. Learners solve problems that test real understanding. Clicking through slides doesn't count.", + title: "Teach, then check understanding", + description: "Each knowledge point has an instruction, a worked example, and problems. Answers update the learner model. The usefulness of that evidence depends on the quality of the course questions.", }, { - number: "4", - title: "Keep what you learned", - description: - "Spaced review brings knowledge back at the right time. It's built into the product, not a feature you have to turn on. Students retain what they learned instead of losing it.", + title: "Return for review", + description: "The system schedules review of previously studied concepts. Learners can answer further questions and revisit explanations as they continue the course.", }, ]; export default function HowItWorksPage() { return (
-
-
-

- How it works -

-

- How Graspful works -

-

- Diagnosis, mastery, and spaced review. Not a video player with a - progress bar. -

+
+
+

How Graspful works

+

Your course supplies the lessons, questions, and prerequisites. Graspful uses learner answers to select practice and schedule review.

-
-
-
- {steps.map((step) => ( -
-

- Step {step.number} -

-

- {step.title} -

-

- {step.description} -

+
+
    + {steps.map((step, i) => ( +
  1. + +
    +

    {step.title}

    +

    {step.description}

    - ))} -
-
+ + ))} + +

Learner progress is an estimate based on their answers. Review course content and answer keys before publishing, and use learner feedback to improve the course.

+ Read the authoring guide
); diff --git a/apps/site/src/app/(marketing)/pricing/page.tsx b/apps/site/src/app/(marketing)/pricing/page.tsx index 450203f..87f0e6f 100644 --- a/apps/site/src/app/(marketing)/pricing/page.tsx +++ b/apps/site/src/app/(marketing)/pricing/page.tsx @@ -3,51 +3,21 @@ import { PageShell } from "@/components/site/page-shell"; export default function PricingPage() { return ( - +
-

What creators pay

-

- No platform subscription. No setup fee. Graspful takes 30% of learner - revenue and runs the infrastructure. You keep the rest. -

-
-

Creator share

-

70%

-

Majority of every paid subscription goes to you.

-
-
-

Platform share

-

30%

-

- Covers hosting, the learner app, billing, adaptive engine, and support. -

-
-
-
-

What learners pay

-

- You set the price. Graspful handles checkout, access control, and - subscription management through Stripe. -

-
    - {[ - "Monthly and annual plans", - "Checkout and payouts handled by Stripe", - "Your own branded academy site", - "One platform for course, billing, and learner access", - ].map((item) => ( -
  • - {item} -
  • - ))} +

    Build and review

    +

    Use your coding agent with the CLI or MCP tools to write course YAML. Run local validation and review checks, then sign in to import a draft.

    +
      +
    • Author source files locally.
    • +
    • Validate course structure and run quality checks.
    • +
    • Inspect imported courses in the creator app.
    • +
    • Publish a reviewed course with the CLI or MCP tools.
    - - Start building - + Create free account +
+
+

Paid subscriptions are not available yet

+

Payment setup is still in progress. Pricing and payment terms will be available before paid subscriptions open.

); diff --git a/apps/site/src/app/globals.css b/apps/site/src/app/globals.css index effaa45..20e205c 100644 --- a/apps/site/src/app/globals.css +++ b/apps/site/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@source "../../../../packages/creator-ui/src"; @custom-variant dark (&:is(.dark *)); diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx index df548c5..b212c0f 100644 --- a/apps/site/src/app/layout.tsx +++ b/apps/site/src/app/layout.tsx @@ -13,15 +13,15 @@ const inter = Inter({ export const metadata: Metadata = { title: { - default: `${siteName}: Turn Your Expertise Into a Course Business`, + default: `${siteName}: Author courses with your AI agent`, template: `%s | ${siteName}`, }, description: - "Your expertise. AI's scaffolding. Every course gets adaptive diagnostics, mastery tracking, and spaced review. Launch a live product in minutes, not months.", + "Use your source material and a coding agent to author course YAML. Review lessons, worked examples, and questions before you publish with Graspful.", openGraph: { - title: `${siteName}: Turn Your Expertise Into a Course Business`, + title: `${siteName}: Author courses with your AI agent`, description: - "Your expertise. AI's scaffolding. Every course gets adaptive diagnostics, mastery tracking, and spaced review.", + "Author and review courses with CLI or MCP tools. Publish lessons with diagnostics, practice questions, and scheduled review.", type: "website", }, }; diff --git a/apps/site/src/components/creator/__tests__/api-errors.test.ts b/apps/site/src/components/creator/__tests__/api-errors.test.ts new file mode 100644 index 0000000..786b847 --- /dev/null +++ b/apps/site/src/components/creator/__tests__/api-errors.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiClientFetch } from '@/lib/api-client'; + +vi.mock('@/lib/supabase/client', () => ({ createSupabaseBrowserClient: vi.fn() })); + +afterEach(() => vi.unstubAllGlobals()); + +describe('creator API error details', () => { + it.each([ + [{ message: 'Course needs authored teaching before publication.' }, 'Course needs authored teaching before publication.'], + [{ message: ['Invalid question answer.', 'Course source is required.'] }, 'Invalid question answer.\nCourse source is required.'], + ])('keeps server validation details available for correction', async (body, message) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 400 }))); + await expect(apiClientFetch('/orgs/my-org/courses/import', 'token')).rejects.toMatchObject({ statusCode: 400, message }); + }); + + it('falls back to the HTTP status when an upstream response is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Unavailable', { status: 502, statusText: 'Bad Gateway' }))); + await expect(apiClientFetch('/brands', 'token')).rejects.toMatchObject({ statusCode: 502, message: 'API error: Bad Gateway' }); + }); +}); diff --git a/apps/site/src/components/creator/__tests__/course-editor.test.tsx b/apps/site/src/components/creator/__tests__/course-editor.test.tsx new file mode 100644 index 0000000..ca0d6cf --- /dev/null +++ b/apps/site/src/components/creator/__tests__/course-editor.test.tsx @@ -0,0 +1,248 @@ +import { act, fireEvent, render, screen, waitFor, cleanup } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parse, stringify } from 'yaml'; +import { CourseYamlSchema, scaffoldCourseObject } from '@graspful/shared'; +import { COURSE_CONTENT_TEMPLATE, parseBrandSettings } from '@graspful/creator-ui/contracts'; +import NewCoursePage from '@/app/(app)/creator/manage/page'; +import EditCoursePage from '@/app/(app)/creator/manage/[courseId]/page'; + +const mocks = vi.hoisted(() => ({ api: vi.fn(), push: vi.fn(), orgSlug: 'my-org' })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + useParams: () => ({ courseId: 'course-id' }), +})); +vi.mock('next/dynamic', () => ({ default: () => function Editor(props: { + value: string; onChange: (value: string) => void; options?: { readOnly?: boolean }; +}) { + return