checkd is a chemistry-lab micro-credential platform built with Next.js 15 (App Router). Instructors create and manage courses, students learn through checkpoint-gated lesson videos and submit badges, and checkers grade badges in person against rubrics. It is backed by Clerk for authentication and Prisma/PostgreSQL for data.
Naming: "checkd" is the product name used in the UI. The repository and some code/metadata still carry the earlier working name ChemSkills (and the
chem-skillsrepo directory) — the two refer to the same app. Renaming the code is tracked as cleanup.
Status: actively evolving. The student and instructor/checker journeys are largely wired end to end against real data; a few surfaces are stubbed or feature-flagged (see Known Issues). What began as a single-seeded, student-only demo is now a multi-role platform. The CHEM101 seed still exists as a reproducible demo world, but the app no longer depends on hand-seeded data — courses, badges, lessons, and enrollments are created through the UI.
- Framework: Next.js 15 with TypeScript, App Router, and CSS Modules. Turbopack dev server.
- Auth: Clerk.
ClerkProviderwraps the app inapp/layout.tsx;middleware.tsenforces auth withauth.protect()for all non-public routes (public routes:/,/splash,/sign-in,/sign-up,/qr/assessment,/api/health). - Roles: There is no global role field. A user's role is derived from their
Enrollment.rolein a given course —STUDENT,INSTRUCTOR, orCHECKER(checker). The same person can hold different roles in different courses. - Data: Prisma + PostgreSQL (
prisma/schema.prisma). Data is created through the app (course creation, badge creation, joins, enrollments, submissions, assessments).useStudentData/useMyCoursesfetch the signed-in user's graph via API routes, cached with SWR. - Data fetching: SWR for client caching; route handlers under
app/api/**back every flow. - UI: Marketing splash for signed-out visitors, an onboarding flow for new users, and a shared sidebar + global header layout for the authenticated app. Animations via framer-motion; icons via Iconify.
- Testing & Quality: Jest/RTL (
npm test, suites under__tests__/plus co-located page tests) and ESLint + Prettier (npm run lint).
-
Architecture diagram:
-
App layer: Next.js 15 App Router, mostly Client Components that render from the Clerk session and SWR-cached API data. The root layout is
force-dynamic(every route is auth-gated and reads search params), so nothing is statically prerendered. -
Authentication: Clerk.
middleware.tsactively protects all non-public routes; individual pages additionally redirect to/splashwhen the session is missing. Sign-in/sign-up live underapp/(auth). -
Onboarding: New Clerk sign-ups don't exist in the database yet (the Clerk webhook is stubbed). On completing onboarding (
/onboarding→POST /api/onboarding), the user is upserted with name, demographics, chosen avatar base, and an analytics row. On subsequent requests,ensureCurrentUser()matches the Clerk email to the DB user. -
Data & APIs: A broad route-handler surface under
app/api/**covers courses (create/join/duplicate/enrollments/students/badges/import), badges, assessments and assessment access codes, checkpoints/attempts, lessons (start/grade/survey), progress, profile, messages, uploads, QR generation, and health. -
Video & QEV: Lesson playback supports YouTube (via the YouTube Iframe API) and Mux playback IDs (schema field
muxPlaybackId). The lesson player renders in-video checkpoints — a "Question Embedded Video" (QEV) experience — pausing to quiz the student and record responses. Direct Mux uploads are stubbed (see Known Issues). -
QR & assessment codes: Badge assessment QR codes are generated server-side by
/api/qrusing theqrcodepackage (no external QR service). QR payloads plus short-livedAssessmentAccessCodes let a checker validate a student's badge in person at/qr/assessment. -
Rubric assessment: Badges can carry a
RubricGoalwith weightedRubricSubgoals. Checkers submit pass/fail, score, points, feedback, and per-subgoal responses (AssessmentAttempt+AssessmentSubgoalResponse); a pass advances the badge toREADY_FOR_FINALIZATION.
Key Prisma models (prisma/schema.prisma):
- User (
@@map("Student")) — person; demographics, avatar, analytics; relations to enrollments, progress, created courses/badges, assessments, messages. - Course / CourseSettings / CourseContact — a course with join
codeandcheckerCode, section metadata, an Iconify-based course image, contacts (instructor/checker), and feature settings (cooldown override, checker messages, cross-section view). - Enrollment / EnrollmentSection — links a user to a course with a
role(STUDENT/INSTRUCTOR/CHECKER) andstatus(PENDING/ACTIVE), optionally scoped to sections. - Lesson / LessonSegment / LessonCheckpoint / CheckpointQuestion / LessonSkill — lesson content, video segments, in-video checkpoints and their multiple-choice questions.
- Progress: LessonProgress, SegmentProgress, CheckpointAttempt, CheckpointResponse.
- Badge / BadgeRequirement / StudentBadge / RubricGoal / RubricSubgoal — badge definitions (with library import lineage via
sourceBadgeId), per-student status (LEARNING → READY_FOR_ASSESSMENT → READY_FOR_FINALIZATION → COMPLETED), and rubrics. - AssessmentAttempt / AssessmentSubgoalResponse / AssessmentAccessCode — in-person grading records and short-lived access codes.
- SurveyPrompt / SurveyResponse — lesson- and badge-context feedback surveys.
- Message / MessageReceipt — in-app messaging. One authored
Messagecarries the body, sender, course/badge context, and an audience descriptor (DIRECT,ALL_STUDENTS,BADGE_INCOMPLETE); aMessageReceiptper person records who it reached and their own read state. The audience is frozen at send time, so later enrollees never see older mail and unenrolled students keep what they already received. Staff copied on a blast get anisObserverreceipt and are excluded from the sender's read count.
- Node.js 20 (
.node-versionpins 20;package.jsonengines require>=20 <23).nvm usewill pick this up. - npm 10+.
- A PostgreSQL database and a Clerk project (free tier is fine).
git clone <your-fork-or-repo-url> chem-skills
cd chem-skills
npm install # runs `prisma generate` via postinstallThe project reads both .env (loaded by the Prisma CLI) and .env.local (Next.js dev convention). Create them in the repo root:
# Database (Prisma/PostgreSQL connection string)
DATABASE_URL="postgresql://..."
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
CLERK_SECRET_KEY="sk_test_..."
# Public origin — used to build assessment QR codes / short-code links.
# Set in production; omit locally (falls back to the request origin).
NEXT_PUBLIC_APP_URL="https://your-production-host"
# Instructor account for the CHEM101 seed (optional; see below)
SEEDED_DEMO_EMAIL="your-instructor-clerk-email@example.com"
# Dev-only: show the QEV skip/unlock controls on the lesson video page. These let a
# student bypass the video requirement, so omit this outside local dev.
# (Replaces NEXT_PUBLIC_CURRENT_ENVIRONMENT_DEV, still read as a fallback.)
NEXT_PUBLIC_DEV_QEV_SKIP="true"Get Clerk keys by creating a project at https://clerk.com/ and copying the Publishable and Secret keys.
Note on
DATABASE_URL: the Prisma CLI auto-loads.env, while Next.js dev loads.env.local. The seed scripts explicitly load.env.local. KeepDATABASE_URLconsistent between the two files (or you can point them at different databases intentionally — e.g. a dev vs. shared DB). Mismatched values between the two files are a common source of "why is my data different" confusion.
npx prisma migrate devThis applies the migrations in prisma/migrations/ to your DATABASE_URL.
You don't need seed data to use the app — you can sign up, onboard, and create a course from scratch. But the CHEM101 seed gives you a reproducible three-role world for walking every flow:
npm run db:seed # runs prisma/seed.js -> prisma/seed-playtest.js (CHEM101)The seed is additive for users and scoped for course content: it upserts three test users (stable ids, so Clerk links keep working), (re)builds the self-contained CHEM101 course with lessons/badges/checkpoints/surveys/progress, and removes the legacy PLAYTEST course if present. It's idempotent — safe to re-run to reset to the known starting state.
| Role | Course access | |
|---|---|---|
| Instructor | SEEDED_DEMO_EMAIL, or instructor+clerk_test@gmail.com if unset |
owns CHEM101 |
| Student | student+clerk_test@bu.edu |
enrolled as student |
| Checker | checker+clerk_test@bu.edu |
enrolled as active checker (CHECKER) |
There's also an additive single-user seed for quickly adding yourself without touching anything else:
npm run db:seed-user # upserts the user configured in prisma/seed-user.jsFor a full walkthrough of the three roles and the end-to-end loop, see docs/chem101-seed-guide.md.
npm run devVisit http://localhost:3000. Signed-out visitors land on /splash; signing in routes to the dashboard. On a Clerk development instance, create each test account once through /sign-up and use Clerk's fixed verification code 424242.
npm run lint # ESLint + Prettier (auto-fix)
npm test # Jest + React Testing Library
npm run build # production build
npm start # serve the production build
npx prisma studio # inspect the databaseSigned-out visitors see the marketing splash (/splash). Authenticated users get a shared sidebar (Home, Badges, Badge Wallet, Messages, My Analytics, Profile) and a global header that hides on lesson-video routes.
- Splash (
/splash) — Marketing landing for signed-out visitors; redirects authenticated users to/. - Onboarding (
/onboarding) — First-run flow that captures name, demographics, and an avatar base, and creates the DB user viaPOST /api/onboarding. - Auth (
/sign-in,/sign-up) — Clerk-hosted forms underapp/(auth).
- Home / Dashboard (
/) — Role-aware. Shows "My Courses" (instructor-created), "Checker Courses", and "My Enrolled Courses" together — the client deliberately validated a combined view rather than role-gating sections. Supports creating a course, joining by code, duplicating a course, entering an assessment access code, and badge finalization surveys (with?surveyBadge=slugdeep links). - Course list & creation (
/courses,/courses/new) — Browse and create courses (title, sections, description, Iconify course image). - Course detail (
/courses/[courseId]) — Lessons, assigned badges, roster, contacts, and settings; checker view via?view=checker. - Course badge detail (
/courses/[courseId]/[badgeId]) — Class-wide progress for a badge; entry point for assessing a student's badge. - Course dashboard (
/course_dashboard?courseId=...) — Student-facing per-course view. - Roster (
/roster,/roster/[studentId]) — Student roster and per-student detail with per-badge settings (reassessment limit, cooldown).
- Lesson detail (
/lessons/[lessonId]) — Checkpoint timeline, thumbnails (YouTube stills when available), requirements, and resume/start CTA with progress stats. - Lesson video (
/lessons/[lessonId]/video) — Video player with in-video QEV checkpoints; records attempts and grades the lesson (/api/lessons/[lessonId]/grade,/start). - Skills (
/skills/[skillId]) — Skill-level view. - Avatar editor (
/edit_avatar) — Three-step avatar builder (base, face, accessory).
- My Badges (
/my_badges) — Badge overview / management surface. - Badge Wallet (
/badges) — Badges sectioned by status (Completed, Ready for assessment, Ready to be finalized, Still learning) with status-specific modal actions: show QR for in-person assessment, start finalization survey, review feedback, or export a completed badge to LinkedIn. - Badge feedback (
/badges/[badgeSlug]/feedback) — Detailed badge review with status, cooldown messaging, lesson summary, checkpoints, and resources. - Badge creation (
/badge_creation,/badges_creation) — Instructor badge authoring, including rubric goals/subgoals and badge-library imports. - Assessments (
/assessments/[courseId]/students/[studentId]/badges/[badgeId]) — Checker grading flow: pass/fail, score, points, feedback, and per-subgoal notes. - QR assessment (
/qr/assessment,/qr/assessment-code) — In-person validation entry points (the first is public so a checker's device can open it without a session).
- Profile (
/profile) — Consolidated student controls: sensitive-field auto-hide with Clerk re-auth, demographics editor, language selector, course contacts, quick stats, and security actions. Also carries the analytics panel (progress tiles and badge summary, viaapp/components/AnalyticsPanel) that used to live on a standalone/analyticsroute. - Messages (
/messages) — Received and Sent tabs, both cross-course and role-agnostic, with a newest/oldest sort. Sent shows each message's read count over its student audience. The sidebar carries an unread badge. - Report (
/report) — Reporting surface. - Instructor QEV prototype (
/instructor/qev-demo) — Standalone cue-point authoring demo (predates the integrated in-lesson QEV player). - Grades (
/grades) & Settings (/settings) — Placeholder "coming soon" pages retained for parity; functional controls live on Profile. Not currently in the sidebar nav.
Route handlers live under app/api/**. Highlights:
- Courses:
courses(list/create),courses/mine,courses/created,courses/enrolled,courses/checker,courses/join, and per-coursecourses/[courseId](detail,duplicate,enrollments,students,badges,badges/import). - Badges & assessment:
badges,badges/[badgeId](+assess,feedback,survey),badges/export/[id],assessment-codes,attempts/[skillId]. - Lessons & progress:
lessons/[lessonId](+start,grade,survey),checkpoints/[checkpointId]/attempt,checkpoint-snapshot,progress/[skillId]. - User:
onboarding,profile/demographics,profile/display-name,profile/reverify,demo/student(aggregate student graph),messages. - Media & infra:
qr(server-generated PNG, rate-limited),uploads/file,uploads/video(Mux — stubbed),youtube-title,webhooks/clerk(stubbed),webhooks/mux(stubbed),health. - Shared libs (
lib/):prisma.ts(client singleton),badgeProgress.ts,lessonGrading.ts,checkpointQuestions.ts,courseImage.ts,video.ts,requestOrigin.ts; plusapp/api/courses/lib/ensure-user.ts(ensureCurrentUser()).
- Unit/integration: Jest + React Testing Library. Suites under
__tests__/cover API routes (course create/join/duplicate, badge creation/import/feedback, assessment submit/codes, lesson grade, QR) and units (badge-progress,video), plus co-located component tests (app/page.test.tsx,app/lessons/[lessonId]/video.test.tsx). Run withnpm test(ortest:watch,test:coverage). - Linting/formatting:
npm run lint(ESLint with Prettier integration;lint:debugfor verbose output). Husky + lint-staged run ESLint/Prettier on staged files pre-commit. - Suggested next coverage: checker grading end-to-end, QEV checkpoint pass/fail transitions, roster badge-settings edits, onboarding → first course, and auth-redirect guards. Consider running lint + test + build in CI on every PR.
Configured for Railway (railway.toml, NIXPACKS builder):
- Build:
npm run railway:build→prisma generate && next build. - Start:
npm run railway:start→prisma migrate deploy && next start(migrations run on deploy). - Health check:
/api/health(used by Railway; also a public route).
Provide the same env vars as local (DATABASE_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, NEXT_PUBLIC_APP_URL), and set NEXT_PUBLIC_APP_URL to the public origin so assessment QR codes and short-code links resolve to the deployed host rather than an internal address. Leave NEXT_PUBLIC_DEV_QEV_SKIP (and the legacy NEXT_PUBLIC_CURRENT_ENVIRONMENT_DEV) unset in production — either one exposes controls that let a student skip the video requirement. Any managed Next.js host (Railway/Vercel/Render) works with equivalent config.
- Stubbed integrations:
uploads/video(Mux upload),webhooks/mux, andwebhooks/clerkreturn202 "not yet implemented". New users are created via the onboarding endpoint rather than a Clerk webhook. - Messages are instructor/checker → student only; students have no reply path. Sends go through
POST /api/messages, which picks the audience from the payload:recipientIdfor a 1:1 (from a student's roster profile),badgeIdfor everyone who has not completed that badge (the badge's Remind button), orallStudentsfor the whole course (Message students on the course detail page). A CHECKER may send only while the course'sallowCheckerMessagessetting is on, and reaches only their own sections unlessallowCrossSectionViewis also on. The whole-course audience is instructor-only regardless of either setting. - Checkpoint snapshots rely on YouTube thumbnails rather than true timestamp stills (accurate stills would require processing the source video).
- Completion metrics can be inaccurate in edge cases; percentage math needs reinforcement.
- Some UI surfaces are not fully aligned with the intended Figma design (badge/skill-tracking screens), and the standalone
/instructor/qev-demoprototype overlaps with the integrated in-lesson QEV player. - Naming not unified in code: the UI is branded "checkd", but the repo, directory (
chem-skills), page metadata ("ChemSkills Demo"), and health-check service name still use the ChemSkills name.
- Roles come from enrollments, not a user field — always resolve capability from
Enrollment.rolefor the relevant course. - Navigation highlighting uses
usePathname; nested badge/lesson routes treat their parent nav item as active. - The sidebar and its nav list live in
app/components/Navigation/Sidebar.tsx(SIDEBAR_NAV). The global header (app/components/GlobalHeader.tsx) hides on lesson-video routes. - See CONTRIBUTING.md for the
app/components/folder convention and sharedlib/location established by the codebase cleanup. - CSS variables and base styles are in
app/globals.css; page-scoped styles use co-locatedpage.module.cssmodules. Media assets live underpublic/. - Keep the CHEM101 seed idempotent and scoped — it must not wipe unrelated data in a shared database.
- Grades/Settings pages are intentional placeholders; put functional student controls on Profile unless product direction changes.
