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
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-quizCMS widget (one per page).QUIZ_WIDGET_TYPESinsrc/lib/course/entry-classifier.tsis narrowed to{"structured-quiz"}; legacy assessment widgets are formative-only. The wire shape (structuredQuizContentSchemain@open-learning-hub/widget-wire-schemas) is mapped into the serverQuizdomain bysrc/lib/cms/quiz-adapter.ts. - Quiz schemas:
Quiz(server-only, includes correct/accepted answers) andQuizPublic(client-safe, strips correct answers). Question types:multiple_choice,true_false,short_answer. QuizPlayer+QuizResults(client) — local state keyed by question id, submission insideuseTransition, per-question validation, single-pass submission.- Server action
submitQuizAttempt({ courseSlug, entrySlug, answers }):- Calls
getEntryAccess(...); rejects unlessmode === 'enrolled'andgated === false. Preview mode returnspreviewReadOnlyand 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 writesentry_progresswhen passed or attempts exhausted. - Returns
{ score, passed, correctCount, totalCount, attemptNumber, attemptsRemaining, perQuestionFeedback? }.
- Calls
- Retry policy reads
quiz.maxAttemptsfrom 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 byuser_id+tenant_id.app/\[locale\]/admin/courses/\[slug\]/dashboard/page.tsx— gated tocourse_admin(whenslug ∈ assignedCourses),tenant_admin, orsuper_admin. Roster, results, per-entry analytics; no content editing.app/\[locale\]/admin/page.tsx— gated byrole ∈ {'tenant_admin','super_admin'}. User list with role promotion (bumpsusers.token_version), course-admin assignment management, enrollment overview, link to OpenAPI/Swagger UI.super_adminonly:/admin/tenantsand cross-tenant user search viawithTenantOverride(). Impersonation start/stop writes toaudit_log.- Tenant lifecycle:
super_admincan create, activate, suspend, archive tenants and manage custom domains perreference/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-levelerror.tsx— friendly messages, never expose stack/SQL, copy is translation-keyed.app/not-found.tsxglobal + per-segment.- Security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) applied in
src/proxy.tsper root.cursor/rules/120-security.mdc. - Structured server logger in
src/lib/log.tswith PII/secret redaction. - PostHog observability: error tracking, product analytics, and OpenTelemetry log forwarding via the shared
@open-learning-hub/observabilitypackage, 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-dompurifyoutside 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 return429on 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.tsgetSiteForHost(host);public/fallback/{favicon.ico, icon-192.png, icon-512.png, apple-icon.png}and generic OG/Twitter cards. Server-only handlers,Uint8Arrayfor binaries, try/catch around every CMS fetch with the sameCache-Controlheaders as the dynamic response. - Favicon:
src/app/api/favicon/route.ts+next.config.tsrewrite/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; emitSitemap: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) viasrc/app/api/well-known/security/route.ts;/.well-known/change-passwordredirects to/learn/forgotinproxy.ts.- CMS revalidation webhook:
src/app/api/cms/revalidate/route.ts—POST /api/cms/revalidate, authenticated byCMS_WEBHOOK_SECRET, validates tag payloads, rate-limits at 60 req/min/IP, callsrevalidateTag.
Reference detail: reference/implementation.md — Phase 10.5, root .cursor/rules/050-apis.mdc.
Operationalization Verification¶
After each phase in this milestone:
npm run check,npm run build,npm run test:components— clean.npm run test:unit— quiz scoring, dashboard query helpers, gating, CMS client error shape.- Manual smoke: take a quiz → review-only after attempts exhausted; access
/adminastenant_adminandcourse_admin(assigned + unassigned); hit/favicon.ico,/manifest.webmanifest,/robots.txt,/sitemap.xml,/opengraph-image,/.well-known/security.txtper tenant. - Trigger the CMS revalidation webhook with valid + invalid secrets and confirm rate limits.
Outstanding items roll into roadmap.md under Implementation Plan Follow-Ups.