Skip to content

Be 24 multiple professors - #96

Merged
jadenScali merged 29 commits into
mainfrom
BE_24-Multiple-Professors
Sep 11, 2026
Merged

jadenScali merged 29 commits into
mainfrom
BE_24-Multiple-Professors

Conversation

@Guneevp

@Guneevp Guneevp commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Classlists have profs (each get their own room admin can join any as TA or their own as prof, students can join any and TAs can join any other profs (not admin) can only join their class.

Ranks are per class (prof ta student).

Admin is now the only global rank.

During a lec slides act as a pass it around. Only prof uploads slides. Only one (TA or prof) controls slides at a time. But at any time a TA or prof can press "Control slides" and they get the UI to change slides while the prev person gets a student view (except they have the "Control slides" to take back control any time.

Guneevp and others added 29 commits September 8, 2026 21:56
Setup before would only show students view even to people who are professor in a course
…onents. letting a simultaneous prof and student use the app
PROFESSOR_WHITELIST is gone. Every login now writes User.role = STUDENT;
PROFESSOR and TA exist only on CourseEnrollment, granted per class by an
admin. ADMIN_WHITELIST is the only remaining global permission.

Two routes read the global role as an authorization shortcut and would have
silently granted a leftover PROFESSOR user access to every course:
  - GET /api/sessions/[sessionId] skipped the enrollment check entirely
  - POST /api/questions/[id]/answers let a global professor answer anywhere
Both now require an enrollment like everyone else.

The Socket.IO viewer count filtered on the cookie role, which is now always
STUDENT — it would have counted the professor as a viewer of their own room.
It reads the CourseEnrollment role recorded at session:join instead.

DEV_ROLE no longer does anything, so drop it from the auth route, server.ts
and dev-all.ts. The dev:all PROF/TA/STUDENT personas are now just labels for
which tab is which; assign them roles by adding them to a classlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Create Lecture button, the /create-class page and POST /api/courses all
gated on the global PROFESSOR role, which no longer exists. They now check
ADMIN_WHITELIST. Professors are assigned to a class by an admin; they do not
make one.

The home page header was gated on having at least one course, so a brand-new
admin would have landed on the student empty state with no way to create
anything. It now shows for admins regardless.

Also drops a leftover `role: "PROFESSOR"` from the creator upsert in
POST /api/courses — global roles are always STUDENT now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Classlist, and turns a Course row into a *room*: one professor's Q&A
space inside a classlist. Course gains classlistId and professorId; the room's
display label is derived from the professor's User.name rather than stored, so
it follows them when a placeholder name is replaced.

A room has exactly one professor. Prisma cannot express a partial unique
index, so the invariant is enforced in SQL:

  CREATE UNIQUE INDEX ... ON "CourseEnrollment" ("courseId")
    WHERE "role" = 'PROFESSOR'

The migration backfills existing data rather than leaving it orphaned: every
current course becomes a single-room classlist owned by its creator, extra
PROFESSOR enrollments left over from the co-professor model are demoted to TA,
and a course whose creator was never enrolled gets that enrollment created —
otherwise it would end up with no professor at all.

User.hasLoggedIn tracks whether User.name is real or a placeholder. Step 3b
needs it: an admin types a temporary name for a professor who has never signed
in, and Shibboleth replaces it on first login.

The admin bulk-delete routes and the test cleanups now clear Classlist too —
its createdById FK is RESTRICT, so deleting users failed without it. Those
routes also protected "professors" by the global User.role, which is always
STUDENT now; they check for a PROFESSOR enrollment instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/classlists replaces POST /api/courses. It creates the classlist,
then a room for the admin who made it and a room for every professor they
named. The admin is PROFESSOR of their own room and TA on all the others; each
other professor is PROFESSOR of exactly one room and has no row at all in the
rest, so those rooms are invisible to them.

A professor who also appears on the uploaded roster is deliberately left out
of the student enrollments — otherwise the roster would hand them student
access to every room the split exists to keep them out of.

Room labels are the professor's surname alone, derived from User.name at read
time rather than stored. Two Smiths on one classlist get a first initial, and
two J. Smiths fall back to the UTORid (src/lib/roomLabel.ts, unit-tested).

The create form now takes a UTORid and a name per professor instead of a
textarea of UTORids. GET /api/users/lookup (admin-only) resolves each UTORid as
it is typed: someone who has signed in has their real name filled in and
locked, and someone who has not gets an amber note that the name is temporary
until they sign in for the first time.

Verified against the dev database: room count, one professor per room, the
roster/TA overlap resolving to TA, professors having no access to each other's
rooms, and the partial unique index rejecting a second professor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the endpoints the manage screen needs, all of them fanning changes out to
every room so the rooms never drift apart:

  GET/PATCH/DELETE /api/classlists/[id]              detail, rename, delete
  GET/POST/PUT/DELETE .../students                   roster, pooled and synced
  POST/DELETE .../professors                         add or remove a room

Adding a professor builds their room seeded with the class's current roster
and TAs; removing one tears their room down. A professor and a room are the
same thing here, so neither can exist without the other. Removal refuses on
the last professor and on a live room.

Renaming fans out because every room carries its own copy of the code and
semester for display — renaming only the classlist would leave rooms labelled
with the old code.

Roster reads pool across rooms and keep the strongest role, so a TA in one
room is a TA on the class. Removing someone demotes a TA to student or drops a
student — one or the other, never a demote followed by a delete that undoes
it. Room owners are refused here and sent to the professors endpoint.

deleteRooms() in classlistService centralises the manual teardown order that
Course's lack of cascades requires.

Verified over HTTP against a dev server: 24 checks covering creation, adding
and removing professors, roster sync, TA demotion, rename fan-out, the refusal
cases, and deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces CourseViewer and ProfCourseViewer with one ClassBrowser. The gallery
now shows a card per class — TES101, not one card per room — with a badge when
any of its rooms is live. Opening a class swaps the gallery for its rooms,
behind a back link: one card per professor, green with a Join button when
live, and the professor's own room offering Start Live Session / End instead.

Only rooms the viewer is enrolled in are listed, so a professor's class opens
onto exactly one room while a student's opens onto all of them. That filtering
is the API's job, not the UI's — GET /api/classlists already returns only what
the caller can reach.

CourseViewer paired a course with its live session by matching course.name
against session.course.name, which quietly broke as soon as two classes shared
a name. The session now arrives attached to its room.

ManageClassModal moves to the classlist endpoints. Its Staff tab becomes
Rooms & TAs: the professor list is now a list of rooms showing who runs each,
how many members it has, and whether it is live, with the same UTORid-plus-name
entry the create form uses. Removing one says plainly that it deletes the room.

Onboarding picked its walkthrough from the global role, which is always STUDENT
now — a professor would have been shown the student one. It waits for the
enrollment answer and branches on that instead.

Verified across three signed-in dev servers: 25 checks that an admin sees every
room (professor of theirs, TA of the rest), a professor sees only their own and
is refused the others' sessions, roles and management, and a student sees all
rooms as a student. Plus 8 render tests for the drill-down itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six things the rest of the app still assumed rooms were shared:

- A socket switching or leaving a session left `session:<id>:instructors`
  behind. A professor moving to a room where they are a student would have kept
  receiving the old room's INSTRUCTOR_ONLY questions.

- POST /api/courses/[id]/students accepted role: "PROFESSOR", which now trips
  the partial unique index and surfaces as a 500. It refuses with an
  explanation and points at the classlist endpoint that creates a room.

- Its DELETE still carried the co-professor guards ("cannot remove the last
  professor"). A room's professor is the room, so demoting them is refused
  outright rather than conditionally.

- PATCH/DELETE /api/courses/[courseId] are gone. Renaming one room desynced it
  from its classlist's code, and deleting one stranded the classlist. Both live
  at classlist level now, and nothing called these any more.

- A join code let a professor enrol as a student in a colleague's room on the
  same class — walking straight around the isolation. joinSession refuses when
  the joiner already runs a room on that classlist.

- The admin room-delete and user-delete routes left classlists behind with no
  rooms: invisible on the home page, and a RESTRICT foreign key that blocks
  deleting the user who created them. Both clean up what they empty.

stress-tests/seed.ts also built a room with no classlist and no professor set,
and gave its professor the global PROFESSOR role that no longer means anything.

Verified against three signed-in dev servers: 13 checks covering each refusal
and that a professor can still manage TAs on their own room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every room on a class carries the same code, so the dashboard's course table
listed N identical "TES101" rows with no way to tell them apart. Rows now show
whose room each is, search matches the professor's name or UTORid, and the tab
and delete copy say "rooms". The stats bar counts classlists separately from
rooms, which is the number that differs when a class has several professors.

FEATURES.md and the admin guide described professor-created courses with
co-professors sharing one room. They now describe classlists, per-professor
rooms, who can see what, and where each kind of management lives — the class
gear icon for admins, Manage TAs inside a session for a room's professor.

Also clears comments still referring to co-professors and the global role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The preview table cut off at four rows and said "Showing 4 of 5 students" with
no way to see the rest — an admin could not check that the roster they were
about to upload was actually right.

It now pages four at a time with prev/next arrows, a position readout, and a
page counter. Short last pages are padded with spacer rows so the card keeps
its height instead of jumping, and the controls are hidden entirely when
everyone already fits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name an admin typed for a professor was being written onto that person's
User record as a placeholder, then overwritten with their real name the first
time they signed in — so the label an admin chose could change under them, and
the form had to warn about it. That is backwards: the name is the room's, not
the professor's.

Course.name now holds it. It is typed at creation, never rewritten by a login,
survives a class rename, and only an admin changes it — via a new
PATCH /api/classlists/[id]/rooms, or by clicking the name in the Rooms tab.

Nothing branches on whether someone has signed in before, so the machinery that
existed to decide that is gone: User.hasLoggedIn, GET /api/users/lookup, the
autofill, the amber "temporary name" warning, and the surname disambiguation
that silently rewrote two Smiths into initials. Two rooms may now share a name
if an admin gives them one; that is the admin's call to make.

upsertPersonByUtorid no longer writes names at all. A new user is named after
their UTORid until Shibboleth gives us the real one.

Fixes a bug this surfaced: adding a professor to an existing class filtered the
creating admin out of the new room's TA list, because the filter that keeps
sibling professors out did not exempt them. The admin was left with no
enrollment, so the room they had just created was invisible on their own home
page. Verified with 80 checks across four probes against live dev servers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header subtitle and the empty state both announced an empty list, one
right above the other. The empty state already says it, with more room to
explain what to do about it, so the subtitle now only appears when there are
classes to describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roster's UTORids were devstu2..devstu5, which read like dev:all personas —
easy to paste into the professor boxes by mistake, and the class then gets
built with students running the rooms. They are rosterstuN now, so nothing on
the roster can be confused for someone who should be teaching.

Both student personas are on the roster, so the student tabs actually see the
class, and .env gains a step-by-step walkthrough: which port is whom, what to
type at each field, and what each tab should show at every stage.

Verified by running that walkthrough end to end against six signed-in dev
servers — 37 checks covering creation, isolation, going live, renaming a room,
adding and removing a professor, and the refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CSV listed only students, so there was nothing in it to tell you which
UTORids to type into the professor boxes — and the roster's own names were the
nearest thing to hand, which is how two students ended up running rooms twice
in a row.

Its last three rows are now devprof2, devprof3 and devta, matching ports
3001 / 3002 / 3003. The bottom of the file is exactly what goes into the
create form.

Having them on the student roster is also the case most worth testing: a
professor who appears on it must not be handed student access to a colleague's
room, and a TA on it must come out a TA. The .env walkthrough now calls both
out as things to check in step 2.

Verified against six signed-in dev servers running the documented steps —
43 checks, including that each professor's only row is PROFESSOR in their own
room, that devta is a TA in all three, and that the manage screen lists 5
students rather than all 8 roster rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A room's professor and its TAs teach the same lecture off the same deck, so
they now share control of it. `slide:change` accepts either; uploading still
accepts only the professor, so a TA can move the slides everyone sees but can
never swap the file out from under them.

requireSocketInstructor enforced PROFESSOR while the `:instructors` socket room
it shares a word with contains professors *and* TAs — reading it as either one
was a standing invitation to widen access by accident. It is gone, replaced by
requireSocketProfessor and requireSocketProfessorOrTA. Both call sites had to
be revisited to compile, which is the point: answer-mode stays the professor's,
slide movement does not.

The viewer's two-state sync toggle becomes three modes:

  following   track the shared page — the default for students and TAs
  browsing    move around privately, room untouched
  presenting  moving the page moves it for the whole room

Present takes the deck and pulls the room to the presenter's page so nobody has
to guess where it went, and a presenter accepts another presenter's move rather
than fighting it, so two of them converge instead of drifting. The professor
still arrives presenting, and now has a way to stop — which they need, since a
TA may be the one driving.

Students are unchanged except in wording: "Following Professor" is no longer
always true, so it reads "Following Live".

Covered by 9 integration tests over the two guards, including that a TA of a
neighbouring room on the same class gets nothing here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the concurrent-presenter model with exclusive control, which is what
a lecture actually looks like: one person has the deck, and a TA who wants it
takes it.

A professor or TA who is not driving sees exactly the bar a student sees —
Following / Browse Freely — plus a Control Slides button. Pressing it claims
the deck and pulls the room to the page they were already on, so taking over
while reading ahead brings everyone along instead of stranding them. The
displaced holder, professor included, drops to that same student bar with the
same button back, so control passes back and forth with no release step.

Control lives in Redis per session and is unclaimed by default, resolving to
the room's professor until someone presses the button — a professor who starts
a lecture and never touches it stays in charge with no extra write. slide:change
now checks it: being on staff is no longer enough, because a page change from
someone who is not driving would yank the deck out from under whoever is.

The logic moved to lib/slideControl.ts rather than staying private to a socket
handler, which is what makes it testable — 5 new tests cover the professor
fallback, takeover displacing rather than queueing, and a room with no
professor having no holder at all.

performSessionEnd cleared no Redis state, so an ended lecture kept its slide
position and answer mode for 24 hours. It clears all three now, which also
gives releaseSlideControl a caller outside the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jadenScali
jadenScali merged commit 7560a7e into main Sep 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants