Implemented the ISSUE-007 operational data foundation:
Student, Course, Cohort, Enrollment, and CohortLessonVisibility. Course/Cohort are identity registries (key only; display title/period live in content-manifest, not the DB).20260527001000_domain_student_cohort (initial), 20260604000000_cohort_slug, and 20260604120000_course_table_cohort_key (Course table + Cohort.courseId/key, drop redundant indexes, add Cohort.updatedAt, DB CHECK on key format).enrollStudents, unenroll, setLessonVisibility, resolveCohortId/findCohortByKeys ([course]/[cohort] resolution), normalizeEmail, normalizeLessonId, createCohortManifestLessonRegistry, and maskEmail.pnpm db:sync-from-content idempotently upserts Course/Cohort rows from the manifest index by key (content is SoT) and reports orphan DB rows without deleting them.pnpm admin:enroll -- --course <key> --cohort <key> --emails <csv> and pnpm admin:visibility -- --course <key> --cohort <key> --lesson-id <lsn_id> --visible <true|false>.content/<course>/<cohort>/cohort.mdx (title/startsAt/endsAt → manifest cohortMeta).Student.email on direct insert/update so raw Prisma writes cannot bypass lowercase/trim storage.RUN_DB_TESTS=true.erDiagram
Course ||--o{ Cohort : "contains (courseId FK)"
Student ||--o{ Enrollment : enrolls
Cohort ||--o{ Enrollment : contains
Cohort ||--o{ CohortLessonVisibility : controls
Student {
string id PK
string email UK
string name
datetime createdAt
datetime updatedAt
}
Course {
string id PK
string key UK
datetime createdAt
}
Cohort {
string id PK
string courseId FK
string key UK "(courseId, key)"
datetime createdAt
datetime updatedAt
}
Enrollment {
string id PK
string studentId FK
string cohortId FK
datetime createdAt
}
CohortLessonVisibility {
string id PK
string cohortId FK
string lessonId
boolean visible
datetime createdAt
datetime updatedAt
}
| Function | Behavior |
|---|---|
enrollStudents(emails, cohortId) |
Lowercases/trims emails, creates missing students, enrolls valid non-duplicates, reports invalid and already-enrolled failures |
unenroll(studentId, cohortId) |
Deletes matching enrollment and returns whether a row was removed |
setLessonVisibility(cohortId, lessonId, visible) |
Fetches the cohort once, loads its manifest partition, validates lsn_... in that cohort only, upserts one visibility row per (cohortId, lessonId) |
resolveCohortId(courseKey, cohortKey) |
Resolves [course]/[cohort] keys to a Cohort.id (course lookup + composite-unique), or throws DomainError |
findCohortByKeys(courseKey, cohortKey) |
Repository op returning { id, courseKey, key } for a (course, cohort) key pair |
normalizeEmail(email) |
Returns lowercase/trimmed email or throws DomainError("invalid_email") |
normalizeLessonId(lessonId) |
Returns trimmed lsn_... lesson id or throws DomainError("invalid_lesson_id") |
createCohortManifestLessonRegistry(manifest) |
Lesson registry scoped to one cohort manifest (ISSUE-005) |
createFilesystemCohortLessonRegistry(courseKey, cohortKey) |
Loads lib/generated/content/<courseKey>/<cohortKey>.json and builds registry |
syncFromContent(index, client) |
Idempotently upserts Course/Cohort rows from the manifest index by key; reports orphan DB rows |
maskEmail(email) |
Masks local part before logging or CLI output |
| Command | Result | Evidence |
|---|---|---|
pnpm typecheck |
Passed | tsc --noEmit exited 0 |
pnpm lint |
Passed | eslint . --max-warnings=0 exited 0 |
pnpm test |
Passed | 13 unit test files, 41 tests passed |
pnpm manifest:build |
Passed | 3 lessons / 1 assignmentDoc across 2 cohorts, cohortMeta emitted |
pnpm prisma generate |
Passed | Prisma Client v6.19.3 generated (Course + Cohort.key) |
prettier --check (changed files) |
Passed | All changed source/test files use Prettier code style |
prisma migrate dev (DB apply) + RUN_DB_TESTS=true integration |
Pending | Requires migrate reset of the local dev DB (phantom prior migration diverged it); blocked on explicit consent |
lessonId intentionally has no database foreign key; it references Git MDX lesson ids validated against the cohort partition manifest ((course key, cohort key)), not a global lesson index. Lessons are replicated per cohort, so there is no lessonIdonly index.