Skip to content

Milestone 3: Operationalization

Status: Tracking. Phases 8–10.5 cover the quiz system, role-scoped dashboards, error/security/observability boundaries, and per-tenant well-known assets. See code as authoritative when this milestone diverges.

Reading guide: Detailed phase prose lives in reference/implementation.md. When this milestone and the implementation plan disagree, the implementation plan and the code itself win.

See also:

  • Part 1: Foundational — Project scaffold, design system, persistence, Auth.js, email.
  • Part 2: Core Application — Schemas/CMS client, public site, enrollment, entry pagination.
  • Part 4: Hand-off and Roadmap — Testing/CI, documentation, creator/marketplace proposals.

Overview

This milestone takes the core learner experience and makes it production-grade: real quiz grading, dashboards for students/instructors/admins, error boundaries with structured logging, security headers, rate limiting, and the per-tenant well-known asset surface (favicon, icons, manifest, robots, sitemap, OG/Twitter cards, .well-known/*, CMS revalidation webhook).

Dependency order

Quiz → Dashboards → Errors / Security / Observability → APIs & Well-Known Files

Phase Checklist

Phase Title Status Source
8 Quiz System [x] Phase 8
9 Dashboards [x] Phase 9
10 Error Handling, Security Headers, Observability [ ] Phase 10
10.5 APIs & Well-Known Files [ ] Phase 10.5

Phase 8 — Quiz System

Goal: render quiz entries, accept attempts, score server-side, persist, gate progression.

  • Authoring: graded quizzes are built with the structured-quiz CMS widget (one per page). QUIZ_WIDGET_TYPES in src/lib/course/entry-classifier.ts is narrowed to {"structured-quiz"}; legacy assessment widgets are formative-only. The wire shape (structuredQuizContentSchema in @open-learning-hub/widget-wire-schemas) is mapped into the server Quiz domain by src/lib/cms/quiz-adapter.ts.
  • Quiz schemas: Quiz (server-only, includes correct/accepted answers) and QuizPublic (client-safe, strips correct answers). Question types: multiple_choice, true_false, short_answer.
  • QuizPlayer + QuizResults (client) — local state keyed by question id, submission inside useTransition, per-question validation, single-pass submission.
  • Server action submitQuizAttempt({ courseSlug, entrySlug, answers }):
    • Calls getEntryAccess(...); rejects unless mode === 'enrolled' and gated === false. Preview mode returns previewReadOnly and writes nothing.
    • Re-fetches the quiz from the CMS (never trusts client question shape), grades server-side, persists to quiz_attempts (attempt_number, answers_json, feedback_json), and writes entry_progress when passed or attempts exhausted.
    • Returns { score, passed, correctCount, totalCount, attemptNumber, attemptsRemaining, perQuestionFeedback? }.
  • Retry policy reads quiz.maxAttempts from the CMS; review-only mode kicks in when attempts are exhausted.
  • All UI chrome (Submit / Retry / Review, score line, pass/fail banner, per-question feedback labels) is translation-keyed; question and answer text comes from the CMS.

Reference detail: reference/implementation.md — Phase 8, reference/quiz-system.md, reference/entry-pagination.md.

Phase 9 — Dashboards

Goal: student dashboard + course-admin dashboard + LMS admin dashboard.

  • app/\[locale\]/learn/dashboard/page.tsx — RSC; enrolled courses, in-progress entries, recent quiz scores; data via Kysely scoped by user_id + tenant_id.
  • app/\[locale\]/admin/courses/\[slug\]/dashboard/page.tsx — gated to course_admin (when slug ∈ assignedCourses), tenant_admin, or super_admin. Roster, results, per-entry analytics; no content editing.
  • app/\[locale\]/admin/page.tsx — gated by role ∈ {'tenant_admin','super_admin'}. User list with role promotion (bumps users.token_version), course-admin assignment management, enrollment overview, link to OpenAPI/Swagger UI.
  • super_admin only: /admin/tenants and cross-tenant user search via withTenantOverride(). Impersonation start/stop writes to audit_log.
  • Tenant lifecycle: super_admin can create, activate, suspend, archive tenants and manage custom domains per reference/tenant-configuration.md.
  • Section headings, empty states, table columns, feature-flag labels, and role names rendered to admins all use translation keys.

Reference detail: reference/implementation.md — Phase 9, reference/analytics.md, reference/user-management.md, reference/tenant-configuration.md, admin/admin-panel.md, guides/course-administration.md.

Phase 10 — Error Handling, Security Headers, Observability

Goal: production-grade boundaries.

  • app/error.tsx, app/global-error.tsx, and route-level error.tsx — friendly messages, never expose stack/SQL, copy is translation-keyed.
  • app/not-found.tsx global + per-segment.
  • Security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) applied in src/proxy.ts per root .cursor/rules/120-security.mdc.
  • Structured server logger in src/lib/log.ts with PII/secret redaction.
  • PostHog observability: error tracking, product analytics, and OpenTelemetry log forwarding via the shared @open-learning-hub/observability package, with a consent gate and privacy-safe defaults.
  • CSRF: rely on Auth.js cookies (SameSite=Lax); double-submit token for state-changing API routes outside Auth.js.
  • Sanitise CMS HTML via isomorphic-dompurify outside Portable Text serialisers.
  • Rate limiting: driver abstraction (in-memory dev/test, Upstash production) with the endpoint policy table from root .cursor/rules/120-security.mdc — auth, sign-up, enrollment, quiz submission, and CMS revalidation routes return 429 on exhaustion.

Reference detail: reference/implementation.md — Phase 10, admin/security-and-rate-limiting.md.

Phase 10.5 — APIs & Well-Known Files

Goal: every publicly addressable per-tenant asset is served from the CMS site document with a static public/fallback/ failover, per root .cursor/rules/050-apis.mdc.

Implementation order of preference: Next.js 16 App Router file conventions → /api/* route handlers (only when no convention fits) → public/ static fallbacks.

  • Shared infrastructure: src/lib/cms/site.ts getSiteForHost(host); public/fallback/{favicon.ico, icon-192.png, icon-512.png, apple-icon.png} and generic OG/Twitter cards. Server-only handlers, Uint8Array for binaries, try/catch around every CMS fetch with the same Cache-Control headers as the dynamic response.
  • Favicon: src/app/api/favicon/route.ts + next.config.ts rewrite /favicon.ico/api/favicon.
  • PWA / device icons: src/app/icon.tsx, src/app/apple-icon.tsx.
  • Manifest: src/app/manifest.ts (Next.js emits <link rel="manifest"> automatically).
  • Robots: src/app/robots.ts — disallow /admin, /api, /learn/dashboard, /learn/course/*/entry/*, /learn/verify, /learn/reset; allow everything else; emit Sitemap: for the request host.
  • Sitemap: src/app/sitemap.ts — published-only; sources (main) pages, (main)/blog/\[slug\], (lms)/learn, public course landings; revalidate = 60. Switch to a sitemap index when published-entry count exceeds 50,000 URLs.
  • Social cards: src/app/opengraph-image.tsx (1200×630), src/app/twitter-image.tsx (1200×600); per-route overrides allowed.
  • .well-known: /.well-known/security.txt (RFC 9116) via src/app/api/well-known/security/route.ts; /.well-known/change-password redirects to /learn/forgot in proxy.ts.
  • CMS revalidation webhook: src/app/api/cms/revalidate/route.tsPOST /api/cms/revalidate, authenticated by CMS_WEBHOOK_SECRET, validates tag payloads, rate-limits at 60 req/min/IP, calls revalidateTag.

Reference detail: reference/implementation.md — Phase 10.5, root .cursor/rules/050-apis.mdc.

Operationalization Verification

After each phase in this milestone:

  1. npm run check, npm run build, npm run test:components — clean.
  2. npm run test:unit — quiz scoring, dashboard query helpers, gating, CMS client error shape.
  3. Manual smoke: take a quiz → review-only after attempts exhausted; access /admin as tenant_admin and course_admin (assigned + unassigned); hit /favicon.ico, /manifest.webmanifest, /robots.txt, /sitemap.xml, /opengraph-image, /.well-known/security.txt per tenant.
  4. Trigger the CMS revalidation webhook with valid + invalid secrets and confirm rate limits.

Outstanding items roll into roadmap.md under Implementation Plan Follow-Ups.