diff --git a/documents/ENDPOINT_ARCHITECTURE.md b/documents/ENDPOINT_ARCHITECTURE.md index df135082..445b48ce 100644 --- a/documents/ENDPOINT_ARCHITECTURE.md +++ b/documents/ENDPOINT_ARCHITECTURE.md @@ -242,8 +242,8 @@ Live Canvas OAuth routes are intentionally absent from this table until the priv | Method | Path | Auth | Role | Description | |--------|------|------|------|-------------| -| POST | `/api/course/enter` | Yes | Any | Enter course by ID; syncs session `globalUser.coursesEnrolled` from DB after enroll | -| POST | `/api/course/enter-by-code` | Yes | Any | Enter course by code; syncs session `globalUser.coursesEnrolled` from DB after enroll | +| POST | `/api/course/enter` | Yes | Member | Enter course by ID; requires `isCourseAccessible` (403 if removed faculty); no longer auto-adds faculty to `instructors[]` | +| POST | `/api/course/enter-by-code` | Yes | Any | Enter course by code; students may join without prior enrollment; non-students require `isCourseAccessible` | | GET | `/api/course/current` | Yes | Any | Get current course from session | ### 4.3 Courses & Content (`/api/courses`) @@ -265,7 +265,7 @@ Live Canvas OAuth routes are intentionally absent from this table until the priv | GET | `/admin/course-selection` | Yes | Admin | Admin course selection HTML | | GET | `/api/admin/course-selection` | Yes | Admin | BFF: periods + all courses grouped | | POST | `/api/admin/courses` | Yes | Admin | Create course in period; enroll admin + instructors | -| PUT | `/api/admin/courses/:id` | Yes | Admin | Edit course name, period, instructors | +| PUT | `/api/admin/courses/:id` | Yes | Admin | Edit course name, period, merge-add instructors (`instructorUserIds`), remove instructors (`removeInstructorUserIds` — admin-only; blocks self-removal and platform-admin removal; pulls `coursesEnrolled`, preserves `{courseName}_users` history) | | POST | `/api/admin/courses/:id/ensure-enrollment` | Yes | Admin | Idempotent admin roster enroll on enter | | GET | `/api/admin/users/search?q=` | Yes | Admin | Faculty search for instructor picker | | PUT | `/api/admin/instructor-allowances` | Yes | Admin | Set allowed course names per puid + period | diff --git a/public/scripts/entry/admin-course-selection.ts b/public/scripts/entry/admin-course-selection.ts index 89c694a6..f1410899 100644 --- a/public/scripts/entry/admin-course-selection.ts +++ b/public/scripts/entry/admin-course-selection.ts @@ -11,12 +11,21 @@ import { createUserSearchMultiSelect, type FacultyPickerUser } from '../ui/user-search-multi-select.js'; +import { + createCourseStaffPicker, + type CourseStaffMember +} from '../ui/course-staff-picker.js'; import { authService } from '../services/auth-service.js'; import { startInactivityTracking } from '../services/inactivity-tracker.js'; +type AdminCourseRow = Omit & { + instructorDisplay?: string; + instructors?: CourseStaffMember[]; +}; + interface AdminPeriodSection extends AcademicPeriodDocument { courseCount: number; - courses: (activeCourse & { instructorDisplay?: string })[]; + courses: AdminCourseRow[]; } interface AdminCourseSelectionPayload { @@ -123,7 +132,7 @@ function renderPeriodSection(period: AdminPeriodSection): string { `; } -function renderCourseRow(course: activeCourse & { instructorDisplay?: string }, periodId: string): string { +function renderCourseRow(course: AdminCourseRow, periodId: string): string { const instructors = course.instructorDisplay ?? formatInstructors(course.instructors); return `
@@ -342,7 +351,7 @@ async function openPeriodModal(mode: 'create' | 'edit', period?: AdminPeriodSect async function openCourseModal( mode: 'create' | 'edit', periodId: string, - course?: activeCourse + course?: AdminCourseRow ): Promise { if (!pageData) { return; @@ -370,27 +379,48 @@ async function openCourseModal( } let selectedInstructors: FacultyPickerUser[] = []; - if (mode === 'edit' && course?.instructors) { - selectedInstructors = (course.instructors as InstructorInfo[]) - .filter((i): i is InstructorInfo => typeof i !== 'string') - .map((i) => ({ userId: i.userId, name: i.name, affiliation: 'faculty' })); - } + let staffPicker: ReturnType | null = null; + + const removalDivider = document.createElement('hr'); + removalDivider.className = 'admin-modal-divider'; + removalDivider.hidden = true; + + const removalMount = document.createElement('div'); + removalMount.className = 'course-staff-removal-mount'; + if (mode === 'edit' && course) { nameInput.value = course.courseName; - } - - const instructorPicker = createUserSearchMultiSelect({ - selected: selectedInstructors, - onChange: (sel) => { - selectedInstructors = sel; - } - }); - content.append( - labelField('Course name', nameInput), - labelField('Academic period', periodSelect), - labelField('Instructors (faculty)', instructorPicker) - ); + const roster: CourseStaffMember[] = + course.instructors?.map((i) => ({ + userId: i.userId, + name: i.name, + isPlatformAdmin: i.isPlatformAdmin ?? false + })) ?? []; + + staffPicker = createCourseStaffPicker({ staff: roster }); + removalMount.appendChild(staffPicker.confirmationContainer); + + content.append( + labelField('Course name', nameInput), + labelField('Academic period', periodSelect), + fieldGroup('Course Staff', staffPicker.root), + removalDivider, + removalMount + ); + } else { + const instructorPicker = createUserSearchMultiSelect({ + selected: selectedInstructors, + onChange: (sel) => { + selectedInstructors = sel; + } + }); + content.append( + labelField('Course name', nameInput), + labelField('Academic period', periodSelect), + fieldGroup('Course Staff', instructorPicker) + ); + } const actions = document.createElement('div'); actions.className = 'admin-modal-actions'; @@ -406,6 +436,23 @@ async function openCourseModal( actions.append(cancelBtn, submitBtn); content.appendChild(actions); + const updateSaveEnabled = () => { + if (!staffPicker) { + submitBtn.disabled = false; + return; + } + const pending = staffPicker.hasPendingRemovals(); + removalDivider.hidden = !pending; + submitBtn.disabled = pending && !staffPicker.areRemovalsConfirmed(); + }; + + if (staffPicker) { + staffPicker.onSaveStateChange(updateSaveEnabled); + updateSaveEnabled(); + // Icons render after modal content is in the document + requestAnimationFrame(() => staffPicker?.refreshChipIcons()); + } + const showPromise = modal.show({ type: 'custom', title: mode === 'edit' ? 'Edit course' : 'Create new course', @@ -420,22 +467,38 @@ async function openCourseModal( submitBtn.addEventListener('click', async () => { const courseName = nameInput.value.trim(); const academicPeriodId = periodSelect.value; - const instructorUserIds = selectedInstructors.map((u) => u.userId); if (!courseName) { await showErrorModal('Validation', 'Course name is required.'); return; } + if (staffPicker && staffPicker.hasPendingRemovals() && !staffPicker.areRemovalsConfirmed()) { + await showErrorModal('Validation', 'Confirm each removal by typing the full name.'); + return; + } + + const instructorUserIds = staffPicker + ? staffPicker.getInstructorUserIdsToAdd() + : selectedInstructors.map((u) => u.userId); + const removeInstructorUserIds = staffPicker + ? staffPicker.getRemoveInstructorUserIds() + : undefined; + const url = mode === 'edit' && course ? `/api/admin/courses/${course.id}` : '/api/admin/courses'; const method = mode === 'edit' ? 'PUT' : 'POST'; + const body: Record = { courseName, academicPeriodId, instructorUserIds }; + if (removeInstructorUserIds && removeInstructorUserIds.length > 0) { + body.removeInstructorUserIds = removeInstructorUserIds; + } + const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ courseName, academicPeriodId, instructorUserIds }) + body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok || !data.success) { @@ -449,6 +512,31 @@ async function openCourseModal( await showPromise; } +let fieldGroupIdCounter = 0; + +/** + * fieldGroup - Labelled wrapper for composite controls (chips, buttons, inputs). + * + * Uses a div rather than a label: an implicit label forwards clicks anywhere in + * its box to its first labelable descendant, which made clicking the section + * label or an admin chip press the first instructor's remove button. + * + * @param label - Visible group label + * @param control - Composite control root + * @returns Group element labelled for assistive tech via aria-labelledby + */ +function fieldGroup(label: string, control: HTMLElement): HTMLElement { + const wrap = document.createElement('div'); + wrap.className = 'admin-modal-field'; + wrap.setAttribute('role', 'group'); + const span = document.createElement('span'); + span.id = `admin-modal-field-${++fieldGroupIdCounter}`; + span.textContent = label; + wrap.setAttribute('aria-labelledby', span.id); + wrap.append(span, control); + return wrap; +} + function labelField(label: string, control: HTMLElement): HTMLElement { const wrap = document.createElement('label'); wrap.className = 'admin-modal-field'; diff --git a/public/scripts/entry/course-selection.ts b/public/scripts/entry/course-selection.ts index 0dac4bb2..0d691120 100644 --- a/public/scripts/entry/course-selection.ts +++ b/public/scripts/entry/course-selection.ts @@ -257,15 +257,6 @@ function escapeHtml(text: string): string { return div.innerHTML; } -/** Instructor names to exclude from display (e.g. dev team members) */ -const EXCLUDED_INSTRUCTOR_NAMES = ['Charisma Rusdiyanto', 'Richard Tape']; - -/** - * createCourseCard - * - * @param course any — Course object (id, courseName, instructors, etc.) - * @returns string — HTML for workspace row with course name, instructors, enter/restart buttons - */ function createCourseCard(course: activeCourse & { instructorDisplay?: string }): string { const instructorNames = course.instructorDisplay ?? @@ -279,7 +270,6 @@ function createCourseCard(course: activeCourse & { instructorDisplay?: string }) } return inst.userId || 'Unknown'; }) - .filter((name: string) => !EXCLUDED_INSTRUCTOR_NAMES.includes(name)) .join(', ') || 'No instructors'); return ` diff --git a/public/scripts/ui/course-staff-picker.ts b/public/scripts/ui/course-staff-picker.ts new file mode 100644 index 00000000..d33c0cd9 --- /dev/null +++ b/public/scripts/ui/course-staff-picker.ts @@ -0,0 +1,439 @@ +// public/scripts/ui/course-staff-picker.ts + +/** + * course-staff-picker.ts — Course Staff multi-select for admin edit-course modal. + * + * Renders platform-admin chips (read-only), roster faculty, and a separate incoming-additions row. + * + * @author: EngE-AI Team + * @date: 2026-08-26 + * @version: 1.0.0 + * @description: Admin course staff roster UI with pending-removal confirmation stack. + */ + +import { createTypedNameConfirmInput } from './typed-name-confirm-input.js'; + +export interface CourseStaffMember { + userId: string; + name: string; + isPlatformAdmin: boolean; +} + +export interface CourseStaffPickerOptions { + /** Initial roster from GET /api/admin/course-selection */ + staff: CourseStaffMember[]; + /** Called when faculty add list changes (not pending removals) */ + onChange?: () => void; + searchUrl?: string; +} + +export interface CourseStaffPickerHandle { + root: HTMLElement; + confirmationContainer: HTMLElement; + getInstructorUserIdsToAdd: () => string[]; + getRemoveInstructorUserIds: () => string[]; + areRemovalsConfirmed: () => boolean; + hasPendingRemovals: () => boolean; + refreshSaveState: () => void; + refreshChipIcons: () => void; + onSaveStateChange: (listener: () => void) => void; +} + +interface FacultyPickerUser { + userId: string; + name: string; + affiliation: string; +} + +/** Replace feather icons only inside a subtree (works before/after attach). */ +function replaceFeatherIn(root: HTMLElement): void { + const featherLib = (window as { + feather?: { icons: Record) => string }> }; + }).feather; + if (!featherLib) { + return; + } + + root.querySelectorAll('[data-feather]').forEach((node) => { + if (!(node instanceof HTMLElement)) { + return; + } + const name = node.getAttribute('data-feather'); + if (!name || !featherLib.icons[name]) { + return; + } + const holder = document.createElement('span'); + holder.innerHTML = featherLib.icons[name].toSvg({ + class: node.className, + width: 14, + height: 14, + 'aria-hidden': 'true' + }); + const svg = holder.firstElementChild; + if (svg) { + node.replaceWith(svg); + } + }); +} + +/** Graduation cap icon — feather has no mortarboard; inline SVG matches chip stroke style. */ +function createGraduationCapIcon(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '14'); + svg.setAttribute('height', '14'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('class', 'user-search-chip-icon user-search-chip-icon--graduation'); + svg.setAttribute('aria-hidden', 'true'); + + const cap = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + cap.setAttribute( + 'd', + 'M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z' + ); + + const tassel = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + tassel.setAttribute('d', 'M22 10v6'); + + const base = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + base.setAttribute('d', 'M6 12v5c0 2 2 3 6 3s6-1 6-3v-5'); + + svg.append(cap, tassel, base); + return svg; +} + +/** + * createCourseStaffPicker - Admin Course Staff section with admin vs instructor chips. + * + * Platform admins: red chip, shield icon, no remove control. + * Roster faculty: removable via typed confirmation below the modal divider. + * New searches land in a To add row (green chips) until Save. + * + * @param options - Initial staff roster and optional change callback + * @returns Picker root, confirmation container, and save payload helpers + */ +export function createCourseStaffPicker(options: CourseStaffPickerOptions): CourseStaffPickerHandle { + const root = document.createElement('div'); + root.className = 'course-staff-picker user-search-multi-select'; + + const rosterChips = document.createElement('div'); + rosterChips.className = 'user-search-chips course-staff-roster-chips'; + + const incomingSection = document.createElement('div'); + incomingSection.className = 'course-staff-incoming'; + incomingSection.hidden = true; + + const incomingLabel = document.createElement('div'); + incomingLabel.className = 'course-staff-incoming-label'; + incomingLabel.textContent = 'To add'; + + const incomingChips = document.createElement('div'); + incomingChips.className = 'user-search-chips course-staff-incoming-chips'; + + incomingSection.append(incomingLabel, incomingChips); + + const searchInput = document.createElement('input'); + searchInput.type = 'search'; + searchInput.className = 'admin-modal-input user-search-input'; + searchInput.placeholder = 'Search faculty by name'; + searchInput.setAttribute('autocomplete', 'off'); + searchInput.setAttribute('aria-label', 'Search faculty by name'); + + const results = document.createElement('ul'); + results.className = 'user-search-results'; + results.hidden = true; + + const confirmationContainer = document.createElement('div'); + confirmationContainer.className = 'course-staff-removal-confirmations'; + + const searchUrl = options.searchUrl ?? '/api/admin/users/search'; + const saveListeners: Array<() => void> = []; + + // Faculty already on the course roster (non-admin, not pending removal) + let rosterFaculty: FacultyPickerUser[] = options.staff + .filter((s) => !s.isPlatformAdmin) + .map((s) => ({ userId: s.userId, name: s.name, affiliation: 'faculty' })); + + // Faculty chosen via search — applied on Save, not mixed into roster chips + let incomingFaculty: FacultyPickerUser[] = []; + + const adminStaff = options.staff.filter((s) => s.isPlatformAdmin); + + // Pending removals — userId -> name + const pendingRemovals = new Map(); + const confirmHandles = new Map>(); + + const notifySaveState = () => saveListeners.forEach((fn) => fn()); + const notifyChange = () => options.onChange?.(); + + const REVERT_ANIM_MS = 220; + + const revertingIds = new Set(); + + const revertPendingRemoval = (userId: string) => { + const name = pendingRemovals.get(userId); + if (!name || revertingIds.has(userId)) { + return; + } + + revertingIds.add(userId); + + const confirmEl = confirmHandles.get(userId)?.element; + const chip = rosterChips.querySelector(`[data-user-id="${userId}"]`); + + confirmEl?.classList.remove('typed-name-confirm-block--enter'); + confirmEl?.classList.add('typed-name-confirm-block--exit'); + chip?.classList.add('user-search-chip--reverting-removal'); + + window.setTimeout(() => { + pendingRemovals.delete(userId); + confirmEl?.remove(); + confirmHandles.delete(userId); + revertingIds.delete(userId); + + if (!rosterFaculty.some((f) => f.userId === userId)) { + rosterFaculty = [...rosterFaculty, { userId, name, affiliation: 'faculty' }]; + } + + renderRosterChips(userId); + renderIncomingChips(); + confirmationContainer.hidden = pendingRemovals.size === 0; + notifyChange(); + notifySaveState(); + }, REVERT_ANIM_MS); + }; + + const syncConfirmations = () => { + // Drop DOM + handles for users no longer pending removal + for (const userId of [...confirmHandles.keys()]) { + if (!pendingRemovals.has(userId)) { + confirmHandles.get(userId)?.element.remove(); + confirmHandles.delete(userId); + } + } + + // Append a confirmation block only for newly pending users — keep existing inputs intact + for (const [userId, name] of pendingRemovals.entries()) { + if (confirmHandles.has(userId)) { + continue; + } + const handle = createTypedNameConfirmInput({ + expectedName: name, + onRevert: () => revertPendingRemoval(userId) + }); + handle.onChange(() => notifySaveState()); + confirmHandles.set(userId, handle); + handle.element.classList.add('typed-name-confirm-block--enter'); + handle.element.setAttribute('data-user-id', userId); + confirmationContainer.appendChild(handle.element); + } + + confirmationContainer.hidden = pendingRemovals.size === 0; + notifySaveState(); + }; + + const markPendingRemoval = (userId: string, name: string, chip: HTMLElement) => { + // Brief exit animation before chip moves to pending-removal state + chip.classList.add('user-search-chip--marking-removal'); + window.setTimeout(() => { + pendingRemovals.set(userId, name); + rosterFaculty = rosterFaculty.filter((f) => f.userId !== userId); + renderAllChips(); + syncConfirmations(); + notifyChange(); + }, 220); + }; + + const createInstructorChip = ( + userId: string, + name: string, + options: { pendingRemoval?: boolean; onRemove?: () => void; incoming?: boolean } = {} + ): HTMLElement => { + const chip = document.createElement('span'); + chip.className = 'user-search-chip user-search-chip--instructor'; + if (options.pendingRemoval) { + chip.classList.add('user-search-chip--pending-removal'); + } + if (options.incoming) { + chip.classList.add('user-search-chip--incoming'); + } + chip.setAttribute('data-user-id', userId); + + const icon = createGraduationCapIcon(); + + const label = document.createElement('span'); + label.className = 'user-search-chip-label'; + label.textContent = name; + + chip.append(icon, label); + + if (options.onRemove) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'user-search-chip-remove'; + removeBtn.setAttribute('aria-label', `Remove ${name}`); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', options.onRemove); + chip.appendChild(removeBtn); + } + + return chip; + }; + + const renderRosterChips = (restoredUserId?: string) => { + rosterChips.innerHTML = ''; + + for (const admin of adminStaff) { + const chip = document.createElement('span'); + chip.className = 'user-search-chip user-search-chip--admin'; + chip.setAttribute('data-user-id', admin.userId); + + const icon = document.createElement('i'); + icon.setAttribute('data-feather', 'shield'); + icon.className = 'user-search-chip-icon'; + icon.setAttribute('aria-hidden', 'true'); + + const label = document.createElement('span'); + label.className = 'user-search-chip-label'; + label.textContent = admin.name; + + chip.append(icon, label); + rosterChips.appendChild(chip); + } + + const allFacultyIds = new Set([ + ...rosterFaculty.map((f) => f.userId), + ...pendingRemovals.keys() + ]); + + for (const userId of allFacultyIds) { + const pending = pendingRemovals.has(userId); + const faculty = rosterFaculty.find((f) => f.userId === userId); + const name = pending ? pendingRemovals.get(userId)! : faculty?.name ?? 'Unknown'; + + const chip = createInstructorChip(userId, name, { + pendingRemoval: pending, + onRemove: pending + ? undefined + : () => { + markPendingRemoval(userId, name, chip); + } + }); + if (!pending && userId === restoredUserId) { + chip.classList.add('user-search-chip--restore-enter'); + } + rosterChips.appendChild(chip); + } + + replaceFeatherIn(rosterChips); + }; + + const renderIncomingChips = () => { + incomingChips.innerHTML = ''; + incomingSection.hidden = incomingFaculty.length === 0; + + for (const faculty of incomingFaculty) { + const chip = createInstructorChip(faculty.userId, faculty.name, { + incoming: true, + onRemove: () => { + incomingFaculty = incomingFaculty.filter((f) => f.userId !== faculty.userId); + renderIncomingChips(); + notifyChange(); + } + }); + incomingChips.appendChild(chip); + } + }; + + const renderAllChips = (restoredUserId?: string) => { + renderRosterChips(restoredUserId); + renderIncomingChips(); + }; + + const isUserAlreadySelected = (userId: string): boolean => + adminStaff.some((a) => a.userId === userId) || + rosterFaculty.some((f) => f.userId === userId) || + incomingFaculty.some((f) => f.userId === userId) || + pendingRemovals.has(userId); + + const addUser = (user: FacultyPickerUser) => { + if (isUserAlreadySelected(user.userId)) { + return; + } + incomingFaculty = [...incomingFaculty, user]; + renderIncomingChips(); + notifyChange(); + searchInput.value = ''; + results.hidden = true; + }; + + let debounce: ReturnType | undefined; + searchInput.addEventListener('input', () => { + const q = searchInput.value.trim(); + if (debounce) { + clearTimeout(debounce); + } + if (!q) { + results.hidden = true; + return; + } + debounce = setTimeout(async () => { + try { + const res = await fetch(`${searchUrl}?q=${encodeURIComponent(q)}`, { + credentials: 'same-origin' + }); + const data = await res.json(); + const users = (data.data ?? []) as FacultyPickerUser[]; + results.innerHTML = ''; + for (const user of users) { + if (isUserAlreadySelected(user.userId)) { + continue; + } + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'user-search-result-item'; + btn.textContent = user.name; + btn.addEventListener('click', () => addUser(user)); + li.appendChild(btn); + results.appendChild(li); + } + results.hidden = results.children.length === 0; + } catch { + results.hidden = true; + } + }, 250); + }); + + renderAllChips(); + root.append(rosterChips, incomingSection, searchInput, results); + + return { + root, + confirmationContainer, + getInstructorUserIdsToAdd: () => incomingFaculty.map((f) => f.userId), + getRemoveInstructorUserIds: () => [...pendingRemovals.keys()], + areRemovalsConfirmed: () => { + if (pendingRemovals.size === 0) { + return true; + } + for (const handle of confirmHandles.values()) { + if (!handle.isConfirmed()) { + return false; + } + } + return true; + }, + hasPendingRemovals: () => pendingRemovals.size > 0, + refreshSaveState: () => notifySaveState(), + refreshChipIcons: () => replaceFeatherIn(rosterChips), + onSaveStateChange: (listener: () => void) => { + saveListeners.push(listener); + } + }; +} diff --git a/public/scripts/ui/typed-name-confirm-input.ts b/public/scripts/ui/typed-name-confirm-input.ts new file mode 100644 index 00000000..a6d815a8 --- /dev/null +++ b/public/scripts/ui/typed-name-confirm-input.ts @@ -0,0 +1,169 @@ +// public/scripts/ui/typed-name-confirm-input.ts + +/** + * typed-name-confirm-input.ts — Typed full-name confirmation for destructive admin actions. + * + * Blocks paste/drop; visual states for focus, match, and paste rejection. + * + * @author: EngE-AI Team + * @date: 2026-08-26 + * @version: 1.0.0 + * @description: Reusable name-typing gate before instructor removal. + */ + +export interface TypedNameConfirmInputOptions { + expectedName: string; + /** When set, shows a green Revert control below the input (course-staff undo removal). */ + onRevert?: () => void; +} + +export interface TypedNameConfirmInputHandle { + element: HTMLElement; + isConfirmed: () => boolean; + onChange: (listener: () => void) => void; +} + +/** + * createTypedNameConfirmInput - Build a removal confirmation block with typed name gate. + * + * Renders one-line prompt and an input that must exactly match `expectedName`. + * Paste and drop are blocked with shake + notice. + * + * @param options - expectedName to type for confirmation + * @returns DOM root, isConfirmed(), and onChange subscription + */ +export function createTypedNameConfirmInput( + options: TypedNameConfirmInputOptions +): TypedNameConfirmInputHandle { + const expected = options.expectedName.trim(); + const listeners: Array<() => void> = []; + + const block = document.createElement('div'); + block.className = 'typed-name-confirm-block'; + + const prompt = document.createElement('p'); + prompt.className = 'typed-name-confirm-prompt'; + prompt.append( + document.createTextNode('Type '), + (() => { + const nameEl = document.createElement('strong'); + nameEl.className = 'typed-name-confirm-name'; + nameEl.textContent = expected; + return nameEl; + })(), + document.createTextNode(' to remove them from this course.') + ); + + const inputWrap = document.createElement('div'); + inputWrap.className = 'typed-name-confirm-input-wrap'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'admin-modal-input typed-name-confirm-input'; + input.placeholder = 'Full name'; + input.setAttribute('autocomplete', 'off'); + input.setAttribute('spellcheck', 'false'); + + const check = document.createElement('span'); + check.className = 'typed-name-confirm-check'; + check.setAttribute('aria-hidden', 'true'); + check.textContent = '✓'; + check.hidden = true; + + const feedbackNotice = document.createElement('p'); + feedbackNotice.className = 'typed-name-confirm-feedback-notice'; + feedbackNotice.hidden = true; + + inputWrap.append(input, check); + block.append(prompt, inputWrap); + + if (options.onRevert) { + const actions = document.createElement('div'); + actions.className = 'typed-name-confirm-actions'; + + const revertBtn = document.createElement('button'); + revertBtn.type = 'button'; + revertBtn.className = 'typed-name-confirm-revert'; + revertBtn.textContent = 'Revert'; + revertBtn.addEventListener('click', () => options.onRevert?.()); + + actions.appendChild(revertBtn); + block.appendChild(actions); + } + + block.appendChild(feedbackNotice); + + let showingPasteFeedback = false; + + const setFeedback = (message: string | null) => { + if (!message) { + feedbackNotice.hidden = true; + return; + } + feedbackNotice.textContent = message; + feedbackNotice.hidden = false; + }; + + const notify = () => listeners.forEach((fn) => fn()); + + const updateVisualState = () => { + const value = input.value.trim(); + const matched = value === expected && value.length > 0; + const focused = document.activeElement === input; + const showMismatchError = !focused && value.length > 0 && !matched; + + inputWrap.classList.toggle('typed-name-confirm-input-wrap--match', matched); + inputWrap.classList.toggle('typed-name-confirm-input-wrap--focus', focused && !matched); + inputWrap.classList.toggle('typed-name-confirm-input-wrap--mismatch', showMismatchError); + input.classList.toggle('typed-name-confirm-input--match', matched); + input.classList.toggle('typed-name-confirm-input--focus', focused && !matched); + input.classList.toggle('typed-name-confirm-input--mismatch', showMismatchError); + check.hidden = !matched; + if (showMismatchError) { + setFeedback("The name doesn't match."); + } else if (!showingPasteFeedback) { + setFeedback(null); + } + input.setAttribute('aria-invalid', showMismatchError ? 'true' : 'false'); + notify(); + }; + + const shakeOnPaste = () => { + showingPasteFeedback = true; + setFeedback('Paste is disabled — type the name manually.'); + inputWrap.classList.remove('typed-name-confirm-input-wrap--shake'); + void inputWrap.offsetWidth; + inputWrap.classList.add('typed-name-confirm-input-wrap--shake', 'typed-name-confirm-input-wrap--paste-error'); + window.setTimeout(() => { + inputWrap.classList.remove('typed-name-confirm-input-wrap--shake'); + }, 450); + }; + + const blockClipboard = (event: Event) => { + event.preventDefault(); + shakeOnPaste(); + }; + + // Block paste/drop — user must type the name manually + input.addEventListener('paste', blockClipboard); + input.addEventListener('drop', blockClipboard); + input.addEventListener('cut', (e) => e.preventDefault()); + input.addEventListener('copy', (e) => e.preventDefault()); + + input.addEventListener('input', () => { + inputWrap.classList.remove('typed-name-confirm-input-wrap--paste-error'); + showingPasteFeedback = false; + updateVisualState(); + }); + + input.addEventListener('focus', updateVisualState); + input.addEventListener('blur', updateVisualState); + + return { + element: block, + isConfirmed: () => input.value.trim() === expected && expected.length > 0, + onChange: (listener: () => void) => { + listeners.push(listener); + } + }; +} diff --git a/public/scripts/ui/user-search-multi-select.ts b/public/scripts/ui/user-search-multi-select.ts index 447e4dd1..64d99c50 100644 --- a/public/scripts/ui/user-search-multi-select.ts +++ b/public/scripts/ui/user-search-multi-select.ts @@ -28,6 +28,7 @@ export function createUserSearchMultiSelect(options: UserSearchMultiSelectOption searchInput.className = 'admin-modal-input user-search-input'; searchInput.placeholder = 'Search faculty by name'; searchInput.setAttribute('autocomplete', 'off'); + searchInput.setAttribute('aria-label', 'Search faculty by name'); const results = document.createElement('ul'); results.className = 'user-search-results'; diff --git a/public/styles/course-selection.css b/public/styles/course-selection.css index e7e2b662..f71ae89d 100644 --- a/public/styles/course-selection.css +++ b/public/styles/course-selection.css @@ -1276,11 +1276,22 @@ .user-search-chip { display: inline-flex; align-items: center; - gap: 0.25rem; + gap: 0.35rem; background: #e8f0f8; - padding: 0.2rem 0.5rem; + padding: 0.25rem 0.55rem; border-radius: 999px; font-size: 0.85rem; + border: 1px solid #cbd5e1; + transition: + opacity 0.22s ease, + background-color 0.22s ease, + border-color 0.22s ease, + transform 0.22s ease, + box-shadow 0.22s ease; +} + +.user-search-chip-label { + line-height: 1.2; } .user-search-chip-remove { @@ -1318,3 +1329,247 @@ color: #6b7280; padding: 0 0 0 1rem; } + +.admin-modal-divider { + border: none; + border-top: 1px solid #e5e7eb; + margin: 1rem 0; +} + +.user-search-chip--admin { + background: #fde8e8; + color: #991b1b; + border-color: #f87171; +} + +.user-search-chip--instructor { + background: #e8f0f8; + border-color: #93c5fd; +} + +.course-staff-incoming { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px dashed #d1d5db; +} + +.course-staff-incoming-label { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #6b7280; +} + +.user-search-chip--incoming { + background: #ecfdf3; + border-color: var(--color-chbe-green, #4d7a2f); +} + +.user-search-chip--marking-removal { + opacity: 0.35; + transform: scale(0.96); + border-color: #fca5a5; + box-shadow: 0 0 0 2px rgb(248 113 113 / 0.25); +} + +.user-search-chip--pending-removal { + opacity: 0.55; + text-decoration: line-through; + background: #f3f4f6; + border-color: #d1d5db; + transform: scale(0.98); +} + +.user-search-chip--pending-removal.user-search-chip--reverting-removal { + opacity: 1; + text-decoration: none; + background: #e8f0f8; + border-color: #93c5fd; + transform: scale(1); + box-shadow: 0 0 0 2px rgb(147 197 253 / 0.35); +} + +.user-search-chip--restore-enter { + animation: user-search-chip-restore-enter 0.22s ease; +} + +@keyframes user-search-chip-restore-enter { + from { + opacity: 0.65; + transform: scale(0.94); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.user-search-chip-icon, +.user-search-chip svg.feather, +.user-search-chip-icon--graduation { + width: 14px; + height: 14px; + flex-shrink: 0; + stroke-width: 2; +} + +.course-staff-removal-confirmations { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.typed-name-confirm-block { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.typed-name-confirm-block--enter { + animation: typed-name-confirm-enter 0.28s ease; +} + +.typed-name-confirm-block--exit { + animation: typed-name-confirm-exit 0.22s ease forwards; + pointer-events: none; +} + +@keyframes typed-name-confirm-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes typed-name-confirm-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +.typed-name-confirm-prompt { + margin: 0; + font-size: 0.9rem; + color: #374151; + line-height: 1.4; +} + +.typed-name-confirm-name { + font-weight: 700; +} + +.typed-name-confirm-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.25rem; +} + +.typed-name-confirm-revert { + border: none; + background: none; + padding: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-chbe-green); + cursor: pointer; + text-decoration: none; +} + +.typed-name-confirm-revert:hover { + color: var(--color-chbe-green-dark, #3d6225); +} + +.typed-name-confirm-revert:focus-visible { + outline: 2px solid var(--color-chbe-green); + outline-offset: 2px; + border-radius: 2px; +} + +.typed-name-confirm-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.typed-name-confirm-input-wrap .typed-name-confirm-input.admin-modal-input { + width: 100%; + padding-right: 2rem; + border: 2px solid #d1d5db; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + box-sizing: border-box; +} + +/* Blue while typing — only when not yet matched */ +.typed-name-confirm-input-wrap--focus .typed-name-confirm-input.admin-modal-input, +.typed-name-confirm-input.admin-modal-input.typed-name-confirm-input--focus { + border: 2px solid #2563eb; + box-shadow: 0 0 0 2px rgb(37 99 235 / 0.2); +} + +/* Green when matched — wins over focus and persists after blur */ +.typed-name-confirm-input-wrap--match .typed-name-confirm-input.admin-modal-input, +.typed-name-confirm-input.admin-modal-input.typed-name-confirm-input--match, +.typed-name-confirm-input-wrap--match .typed-name-confirm-input.admin-modal-input:focus, +.typed-name-confirm-input-wrap--match .typed-name-confirm-input.admin-modal-input:focus-visible, +.typed-name-confirm-input.admin-modal-input.typed-name-confirm-input--match:focus, +.typed-name-confirm-input.admin-modal-input.typed-name-confirm-input--match:focus-visible { + border: 2px solid #16a34a; + box-shadow: 0 0 0 2px rgb(22 163 74 / 0.25); +} + +.typed-name-confirm-input-wrap--paste-error .typed-name-confirm-input.admin-modal-input { + border: 2px solid #dc2626; + box-shadow: 0 0 0 2px rgb(220 38 38 / 0.2); +} + +.typed-name-confirm-input-wrap--mismatch .typed-name-confirm-input.admin-modal-input, +.typed-name-confirm-input.admin-modal-input.typed-name-confirm-input--mismatch { + border: 2px solid #dc2626; + box-shadow: 0 0 0 2px rgb(220 38 38 / 0.2); +} + +.typed-name-confirm-feedback-notice { + margin: 0; + font-size: 0.8rem; + color: #dc2626; + line-height: 1.3; +} + +.typed-name-confirm-check { + position: absolute; + right: 0.65rem; + color: #16a34a; + font-weight: 700; + pointer-events: none; +} + +@keyframes typed-name-confirm-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +.typed-name-confirm-input-wrap--shake { + animation: typed-name-confirm-shake 0.45s ease; +} + +.create-new-course-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/src/db/enge-ai-mongodb.ts b/src/db/enge-ai-mongodb.ts index 0c75d01c..c0273908 100644 --- a/src/db/enge-ai-mongodb.ts +++ b/src/db/enge-ai-mongodb.ts @@ -977,6 +977,9 @@ export class EngEAI_MongoDB { public addCourseToGlobalUser = async (puid: string, courseId: string) => GlobalUserMongo.addCourseToGlobalUser(this.ctx(), puid, courseId); + public removeCourseFromGlobalUser = async (puid: string, courseId: string) => + GlobalUserMongo.removeCourseFromGlobalUser(this.ctx(), puid, courseId); + public updateGlobalUser = async (puid: string, updateData: Partial) => GlobalUserMongo.updateGlobalUser(this.ctx(), puid, updateData); @@ -1158,6 +1161,12 @@ export class EngEAI_MongoDB { public enrollInstructorsOnCourse = async (course: activeCourse, instructorUserIds: string[]) => CourseEnrollmentMongo.enrollInstructorsOnCourse(this.ctx(), course, instructorUserIds); + public removeInstructorsFromCourse = async ( + course: activeCourse, + userIdsToRemove: string[], + options?: CourseEnrollmentMongo.RemoveInstructorsFromCourseOptions + ) => CourseEnrollmentMongo.removeInstructorsFromCourse(this.ctx(), course, userIdsToRemove, options); + /** * LMS course links — course-lms-link-mongo.ts */ diff --git a/src/db/mongo/__tests__/course-enrollment-mongo.test.ts b/src/db/mongo/__tests__/course-enrollment-mongo.test.ts index 1e1f8fa2..a449d8ba 100644 --- a/src/db/mongo/__tests__/course-enrollment-mongo.test.ts +++ b/src/db/mongo/__tests__/course-enrollment-mongo.test.ts @@ -1,6 +1,7 @@ jest.mock('../global-user-mongo', () => ({ addCourseToGlobalUser: jest.fn(), - findGlobalUserByUserId: jest.fn() + findGlobalUserByUserId: jest.fn(), + removeCourseFromGlobalUser: jest.fn() })); jest.mock('../course-user-mongo', () => ({ createStudent: jest.fn(), @@ -13,8 +14,8 @@ jest.mock('../../../utils/logger', () => ({ appLogger: { log: jest.fn(), warn: jest.fn(), error: jest.fn() } })); -import { enrollInstructorsOnCourse } from '../course-enrollment-mongo'; -import { findGlobalUserByUserId } from '../global-user-mongo'; +import { enrollInstructorsOnCourse, removeInstructorsFromCourse } from '../course-enrollment-mongo'; +import { findGlobalUserByUserId, removeCourseFromGlobalUser } from '../global-user-mongo'; import { createStudent, findStudentByUserId } from '../course-user-mongo'; import { getActiveCourse } from '../course-mongo'; import type { MongoDalContext } from '../mongo-context'; @@ -72,3 +73,61 @@ describe('enrollInstructorsOnCourse admin bypass', () => { expect(createStudent).not.toHaveBeenCalled(); }); }); + +describe('removeInstructorsFromCourse', () => { + const ctx = {} as MongoDalContext; + const course = { + id: 'course-1', + courseName: 'TestCourse', + instructors: [ + { userId: 'fac-1', name: 'Amira' }, + { userId: 'admin-1', name: 'Admin One' } + ] + } as unknown as activeCourse; + + const facultyUser = { + userId: 'fac-1', + name: 'Amira', + puid: 'puid-fac-1', + affiliation: 'faculty', + isAdmin: false, + coursesEnrolled: ['course-1'] + } as unknown as GlobalUser; + + const adminUser = { + userId: 'admin-1', + name: 'Admin One', + puid: 'puid-admin', + affiliation: 'staff', + isAdmin: true, + coursesEnrolled: ['course-1'] + } as unknown as GlobalUser; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('removes faculty from instructors and pulls coursesEnrolled', async () => { + (findGlobalUserByUserId as jest.Mock).mockResolvedValue(facultyUser); + + const result = await removeInstructorsFromCourse(ctx, course, ['fac-1']); + + expect(result).toEqual([{ userId: 'admin-1', name: 'Admin One' }]); + expect(removeCourseFromGlobalUser).toHaveBeenCalledWith(ctx, 'puid-fac-1', 'course-1'); + }); + + it('rejects removal of platform admin', async () => { + (findGlobalUserByUserId as jest.Mock).mockResolvedValue(adminUser); + + await expect(removeInstructorsFromCourse(ctx, course, ['admin-1'])).rejects.toThrow( + 'Platform admins cannot be removed' + ); + expect(removeCourseFromGlobalUser).not.toHaveBeenCalled(); + }); + + it('rejects self-removal by caller', async () => { + await expect( + removeInstructorsFromCourse(ctx, course, ['fac-1'], { callerUserId: 'fac-1' }) + ).rejects.toThrow('Cannot remove yourself'); + }); +}); diff --git a/src/db/mongo/course-enrollment-mongo.ts b/src/db/mongo/course-enrollment-mongo.ts index 2a0f2487..c868628c 100644 --- a/src/db/mongo/course-enrollment-mongo.ts +++ b/src/db/mongo/course-enrollment-mongo.ts @@ -7,10 +7,20 @@ import type { GlobalUser, InstructorInfo, User, activeCourse } from '../../types/shared'; import { getActiveCourse } from './course-mongo'; import { createStudent, findStudentByUserId } from './course-user-mongo'; -import { addCourseToGlobalUser, findGlobalUserByUserId } from './global-user-mongo'; +import { + addCourseToGlobalUser, + findGlobalUserByUserId, + removeCourseFromGlobalUser +} from './global-user-mongo'; import type { MongoDalContext } from './mongo-context'; import { appLogger } from '../../utils/logger'; import { isAdminUser } from '../../utils/admin'; +import { instructorEntryUserId } from '../../utils/course-staff'; + +export interface RemoveInstructorsFromCourseOptions { + /** Platform admin performing the removal — cannot remove their own userId. */ + callerUserId?: string; +} /** * Idempotent enroll: `$addToSet` on global user + create faculty CourseUser when absent. @@ -92,6 +102,80 @@ export async function enrollInstructorsOnCourse( return instructors; } +/** + * removeInstructorsFromCourse - Removes faculty instructors from catalog roster and global enrollment. + * + * Updates `activeCourse.instructors[]` and `$pull`s `coursesEnrolled` on each removed user. + * Preserves `{courseName}_users` rows and chat history. Platform admins are never removable. + * + * @param ctx - MongoDalContext + * @param course - Course document (catalog row) + * @param userIdsToRemove - Faculty userIds to remove after admin UI confirmation + * @param options - Optional caller userId for self-removal guard + * @returns Updated `instructors[]` with removals applied + * @throws Error when caller removes self, targets a platform admin, or userId is not on roster + */ +export async function removeInstructorsFromCourse( + ctx: MongoDalContext, + course: activeCourse, + userIdsToRemove: string[], + options?: RemoveInstructorsFromCourseOptions +): Promise { + const uniqueIds = [...new Set(userIdsToRemove.filter((id) => typeof id === 'string' && id))]; + if (uniqueIds.length === 0) { + return normalizeInstructors(course.instructors); + } + + const callerUserId = options?.callerUserId; + const current = normalizeInstructors(course.instructors); + const rosterUserIds = new Set(current.map((i) => i.userId)); + + // Validate each removal target before mutating catalog or global enrollment + for (const userId of uniqueIds) { + if (callerUserId && userId === callerUserId) { + throw new Error('Cannot remove yourself from the course'); + } + if (!rosterUserIds.has(userId)) { + throw new Error(`User ${userId} is not an instructor on this course`); + } + + const globalUser = await findGlobalUserByUserId(ctx, userId); + if (globalUser && isAdminUser(globalUser)) { + throw new Error('Platform admins cannot be removed from a course'); + } + } + + const removeSet = new Set(uniqueIds); + // Filter catalog instructors — admins remain even if client sent their ids + const nextInstructors = current.filter((inst) => { + if (removeSet.has(inst.userId)) { + return false; + } + return true; + }); + + // Revoke course-selection access; keep per-course roster documents for history + for (const userId of uniqueIds) { + const globalUser = await findGlobalUserByUserId(ctx, userId); + if (!globalUser) { + appLogger.warn(`[enrollment] removeInstructors: unknown userId ${userId}`); + continue; + } + await removeCourseFromGlobalUser(ctx, globalUser.puid, course.id); + appLogger.log( + `[enrollment] Removed instructor ${globalUser.name} (${userId}) from course ${course.id}` + ); + } + + return nextInstructors; +} + +/** + * normalizeInstructors - Normalizes instructor data for consistent processing. + * + * @param raw - Instructor data to normalize + * @returns Normalized instructor list + */ function normalizeInstructors( raw: InstructorInfo[] | string[] | undefined ): InstructorInfo[] { @@ -99,6 +183,8 @@ function normalizeInstructors( return []; } return raw.map((inst) => - typeof inst === 'string' ? { userId: inst, name: 'Unknown' } : inst + typeof inst === 'string' + ? { userId: inst, name: 'Unknown' } + : { userId: instructorEntryUserId(inst), name: inst.name ?? 'Unknown' } ); } diff --git a/src/db/mongo/global-user-mongo.ts b/src/db/mongo/global-user-mongo.ts index 8e84bb95..e078d01e 100644 --- a/src/db/mongo/global-user-mongo.ts +++ b/src/db/mongo/global-user-mongo.ts @@ -114,6 +114,32 @@ export async function addCourseToGlobalUser( ); } +/** + * removeCourseFromGlobalUser - Pulls a course id from `coursesEnrolled` on `active-users`. + * + * Idempotent: no-op when the user or enrollment row is missing. + * + * @param ctx - MongoDalContext + * @param puid - Global user lookup key + * @param courseId - `activeCourse.id` to remove from the enrolled list + * @returns Promise + */ +export async function removeCourseFromGlobalUser( + ctx: MongoDalContext, + puid: string, + courseId: string +): Promise { + const collection = activeUsersMongoCollection(ctx.db); + // Pull course id so removed instructors lose course-selection visibility + await collection.updateOne( + { puid }, + { + $pull: { coursesEnrolled: courseId }, + $set: { updatedAt: new Date() } + } as any + ); +} + /** * updateGlobalUser * diff --git a/src/helpers/__tests__/course-access.test.ts b/src/helpers/__tests__/course-access.test.ts index 3ed61d0a..000b39bf 100644 --- a/src/helpers/__tests__/course-access.test.ts +++ b/src/helpers/__tests__/course-access.test.ts @@ -1,7 +1,8 @@ import { buildCourseSelectionByPeriod, filterAccessibleCourses, - isCourseAccessible + isCourseAccessible, + mapFacultyInstructorDisplay } from '../course-access'; import type { AcademicPeriodDocument, activeCourse, GlobalUser } from '../../types/shared'; @@ -91,4 +92,23 @@ describe('course-access', () => { expect(payload.periods[1].courseCount).toBe(0); expect(payload.defaultPeriodId).toBe('period-2026'); }); + + it('mapFacultyInstructorDisplay excludes platform admins and TAs', () => { + const courseWithMixedStaff = { + id: 'course-c', + courseName: 'ENGR 303', + instructors: [ + { userId: 'fac-1', name: 'Dr. Smith' }, + { userId: 'admin-1', name: 'Platform Admin' }, + { userId: 'ta-1', name: 'TA Pat' } + ], + teachingAssistants: [{ userId: 'ta-1', name: 'TA Pat' }] + } as activeCourse; + + const display = mapFacultyInstructorDisplay( + courseWithMixedStaff, + new Set(['admin-1']) + ); + expect(display).toBe('Dr. Smith'); + }); }); diff --git a/src/helpers/course-access.ts b/src/helpers/course-access.ts index d0fbe923..0b7bc35d 100644 --- a/src/helpers/course-access.ts +++ b/src/helpers/course-access.ts @@ -4,7 +4,7 @@ import type { AcademicPeriodDocument, activeCourse, GlobalUser, InstructorInfo } from '../types/shared'; import { isAdminUser } from '../utils/admin'; -import { isCourseStaff } from '../utils/course-staff'; +import { isCourseStaff, instructorEntryUserId } from '../utils/course-staff'; import { coursePayloadForViewer } from '../dashboard-setting/course-student-view'; /** True when user may enter or list this course (non-admin). */ @@ -29,14 +29,36 @@ export function filterAccessibleCourses( return allCourses.filter((c) => isCourseAccessible(c, globalUser)); } -function mapInstructorNames(course: activeCourse): string { +/** + * mapFacultyInstructorDisplay - Comma-separated faculty instructor names for course cards. + * + * Excludes platform admin userIds and teaching assistants — cards show assigned faculty only. + * + * @param course - Active course catalog row + * @param platformAdminUserIds - Set of `GlobalUser.userId` values flagged `isAdmin` + * @returns Display string or `No instructors` + */ +export function mapFacultyInstructorDisplay( + course: activeCourse, + platformAdminUserIds: Set +): string { + const taIds = new Set( + (course.teachingAssistants ?? []).map((ta) => instructorEntryUserId(ta)) + ); const names = - course.instructors?.map((inst: InstructorInfo | string) => { - if (typeof inst === 'string') { - return inst; - } - return inst?.name ?? inst?.userId ?? 'Unknown'; - }) ?? []; + course.instructors + ?.map((inst: InstructorInfo | string) => { + const userId = instructorEntryUserId(inst); + // Skip platform admins and TAs on the public instructor line + if (platformAdminUserIds.has(userId) || taIds.has(userId)) { + return null; + } + if (typeof inst === 'string') { + return inst; + } + return inst?.name ?? inst?.userId ?? 'Unknown'; + }) + .filter((name): name is string => Boolean(name)) ?? []; return names.join(', ') || 'No instructors'; } @@ -57,8 +79,10 @@ export function buildCourseSelectionByPeriod( periods: AcademicPeriodDocument[], allCourses: activeCourse[], globalUser: GlobalUser, - defaultPeriodId: string + defaultPeriodId: string, + platformAdminUserIds?: Set ): CourseSelectionPayload { + const adminIds = platformAdminUserIds ?? new Set(); const accessible = filterAccessibleCourses(allCourses, globalUser); const coursesByPeriod = new Map(); @@ -81,7 +105,7 @@ export function buildCourseSelectionByPeriod( courseCount: periodCourses.length, courses: periodCourses.map((c) => ({ ...coursePayloadForViewer(c, globalUser), - instructorDisplay: mapInstructorNames(c) + instructorDisplay: mapFacultyInstructorDisplay(c, adminIds) })) }; }); diff --git a/src/routes/mongo/admin-course-routes.ts b/src/routes/mongo/admin-course-routes.ts index 1b4d2d73..e8258dbb 100644 --- a/src/routes/mongo/admin-course-routes.ts +++ b/src/routes/mongo/admin-course-routes.ts @@ -13,18 +13,33 @@ import type { activeCourse, GlobalUser, InstructorInfo } from '../../types/share import { buildDefaultByWeekCourseContent } from '../../helpers/build-default-course-content'; import { isAdminUser } from '../../utils/admin'; import { routeParam } from '../../helpers/route-params'; +import { mapFacultyInstructorDisplay } from '../../helpers/course-access'; +import { instructorEntryUserId } from '../../utils/course-staff'; const router = Router(); -function mapInstructorNames(course: activeCourse): string { - const names = - course.instructors?.map((inst) => { - if (typeof inst === 'string') { - return inst; - } - return inst?.name ?? inst?.userId ?? 'Unknown'; - }) ?? []; - return names.join(', ') || 'No instructors'; + +/** + * normalizeInstructorRoster - Normalizes instructor data for consistent processing. + * + * @param course - Course data to normalize + * @param platformAdminUserIds - Set of platform admin user IDs + * @returns Normalized instructor list + */ + +function normalizeInstructorRoster( + course: activeCourse, + platformAdminUserIds: Set +): { userId: string; name: string; isPlatformAdmin: boolean }[] { + return (course.instructors ?? []).map((inst) => { + const userId = instructorEntryUserId(inst); + const name = typeof inst === 'string' ? 'Unknown' : (inst.name ?? userId); + return { + userId, + name, + isPlatformAdmin: platformAdminUserIds.has(userId) + }; + }); } router.get( @@ -35,6 +50,8 @@ router.get( const defaultPeriodId = await mongo.getDefaultAcademicPeriodId(); const periods = await mongo.listAcademicPeriods(); const courses = await mongo.getAllActiveCourses(); + const platformAdmins = await mongo.findAdminGlobalUsers(); + const platformAdminUserIds = new Set(platformAdmins.map((u) => u.userId)); const coursesByPeriod = new Map(); for (const period of periods) { @@ -57,7 +74,8 @@ router.get( courseCount: periodCourses.length, courses: periodCourses.map((c) => ({ ...c, - instructorDisplay: mapInstructorNames(c) + instructorDisplay: mapFacultyInstructorDisplay(c, platformAdminUserIds), + instructors: normalizeInstructorRoster(c, platformAdminUserIds) })) }; }); @@ -197,8 +215,9 @@ router.put( '/courses/:id', requireAdminGlobal, asyncHandlerWithAuth(async (req: Request, res: Response) => { + const globalUser = (req.session as any).globalUser as GlobalUser; const courseId = routeParam(req.params, 'id'); - const { courseName, academicPeriodId, instructorUserIds } = req.body ?? {}; + const { courseName, academicPeriodId, instructorUserIds, removeInstructorUserIds } = req.body ?? {}; const mongo = await EngEAI_MongoDB.getInstance(); const existing = await mongo.getActiveCourse(courseId); @@ -207,13 +226,16 @@ router.put( } const updates: Partial = {}; + let workingCourse = existing as activeCourse; if (courseName !== undefined) { if (typeof courseName !== 'string' || !courseName.trim()) { return res.status(400).json({ success: false, error: 'courseName must be a non-empty string' }); } + const trimmed = courseName.trim(); const duplicate = await mongo.getCourseByName(trimmed); + if (duplicate && duplicate.id !== courseId) { return res.status(409).json({ success: false, error: 'Course name already exists' }); } @@ -228,8 +250,23 @@ router.put( await mongo.linkCourseToPeriod(courseId, academicPeriodId); } + // Apply explicit removals before merge-add so stale roster rows are cleared first + if (Array.isArray(removeInstructorUserIds) && removeInstructorUserIds.length > 0) { + try { + const ids = removeInstructorUserIds.filter((x: unknown) => typeof x === 'string') as string[]; + const instructors = await mongo.removeInstructorsFromCourse(workingCourse, ids, { + callerUserId: globalUser.userId + }); + updates.instructors = instructors; + workingCourse = { ...workingCourse, instructors }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Instructor removal failed'; + return res.status(400).json({ success: false, error: message }); + } + } + if (Array.isArray(instructorUserIds)) { - const instructors = await mongo.enrollInstructorsOnCourse(existing as activeCourse, instructorUserIds); + const instructors = await mongo.enrollInstructorsOnCourse(workingCourse, instructorUserIds); updates.instructors = instructors; } diff --git a/src/routes/route-course-entry.ts b/src/routes/route-course-entry.ts index ad7c1e84..2bc8a273 100644 --- a/src/routes/route-course-entry.ts +++ b/src/routes/route-course-entry.ts @@ -11,7 +11,7 @@ import { GlobalUser, CourseUser, User, activeCourse } from '../types/shared'; import { appLogger } from '../utils/logger'; import { refreshSessionGlobalUser } from '../helpers/session-global-user'; import { isCourseStaff, isInCourseTAs } from '../utils/course-staff'; -import { isAdminUser } from '../utils/admin'; +import { isCourseAccessible } from '../helpers/course-access'; import { resolveInstructorModeRedirect } from '../helpers/instructor-onboarding-redirect'; const router = express.Router(); @@ -54,61 +54,18 @@ router.post('/enter', asyncHandlerWithAuth(async (req: Request, res: Response) = } appLogger.log(`[COURSE-ENTRY] Course found: ${course.courseName}`); - - // 1.5. Handle instructor joining existing course - if (globalUser.affiliation === 'faculty' || isAdminUser(globalUser)) { - const courseData = course as any; - const instructorUserId = globalUser.userId; - const instructorName = globalUser.name; - - // Helper function to check if instructor is already in the array (handles both old and new formats) - const isInstructorInArray = (instructors: any[]): boolean => { - if (!instructors || instructors.length === 0) return false; - return instructors.some(inst => { - if (typeof inst === 'string') { - return inst === instructorUserId; // Old format - } else if (inst && inst.userId) { - return inst.userId === instructorUserId; // New format - } - return false; - }); - }; - - // Check if instructor is already in the course's instructors array - if (!isInstructorInArray(courseData.instructors || [])) { - appLogger.log(`[COURSE-ENTRY] Instructor ${instructorUserId} not in course instructors list, adding...`); - - // Get existing instructors and convert to new format if needed - const existingInstructors = courseData.instructors || []; - const updatedInstructors = existingInstructors.map((inst: any) => { - // Convert old format to new format if needed - if (typeof inst === 'string') { - return { userId: inst, name: 'Unknown' }; // Will be updated later if needed - } - return inst; // Already in new format - }); - - // Add new instructor with name - updatedInstructors.push({ - userId: instructorUserId, - name: instructorName - }); - - await mongoDB.updateActiveCourse(courseId, { - instructors: updatedInstructors - } as any); - - appLogger.log(`[COURSE-ENTRY] Added instructor ${instructorName} (${instructorUserId}) to course's instructors list`); - } - - // Ensure instructor is enrolled in the course (add to coursesEnrolled) - if (!globalUser.coursesEnrolled.includes(courseId)) { - await mongoDB.addCourseToGlobalUser( - globalUser.puid, - courseId - ); - appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to instructor's enrolled list`); - } + + const courseData = course as unknown as activeCourse; + + // Block removed faculty; students joining by code are allowed through below + if (globalUser.affiliation !== 'student' && !isCourseAccessible(courseData, globalUser)) { + return res.status(403).json({ error: 'Course membership required' }); + } + + // Keep coursesEnrolled in sync for roster staff without re-adding instructors[] + if (isCourseStaff(courseData, globalUser) && !globalUser.coursesEnrolled.includes(courseId)) { + await mongoDB.addCourseToGlobalUser(globalUser.puid, courseId); + appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to staff enrolled list`); } // 2. Check if CourseUser exists in {courseName}_users @@ -184,7 +141,6 @@ router.post('/enter', asyncHandlerWithAuth(async (req: Request, res: Response) = let redirect: string; let requiresOnboarding = false; - const courseData = course as unknown as activeCourse; const isTA = isInCourseTAs(courseData, globalUser.userId); if (isTA && !globalUser.coursesEnrolled.includes(courseId)) { @@ -192,8 +148,7 @@ router.post('/enter', asyncHandlerWithAuth(async (req: Request, res: Response) = appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to TA enrolled list`); } - const isStaff = - isCourseStaff(courseData, globalUser) || globalUser.affiliation === 'faculty'; + const isStaff = isCourseStaff(courseData, globalUser); // Sync session globalUser after enrollment mutations (coursesEnrolled drift fix). // Must precede the instructor redirect, which now reads per-user tutorial progress. @@ -277,63 +232,17 @@ router.post('/enter-by-code', asyncHandlerWithAuth(async (req: Request, res: Res appLogger.log(`[COURSE-ENTRY] Course found: ${course.courseName} (ID: ${course.id})`); - // 2. Use the same course entry logic as /enter endpoint const courseId = course.id; - - // 2.5. Handle instructor joining existing course - if (globalUser.affiliation === 'faculty' || isAdminUser(globalUser)) { - const courseData = course as any; - const instructorUserId = globalUser.userId; - const instructorName = globalUser.name; - - // Helper function to check if instructor is already in the array (handles both old and new formats) - const isInstructorInArray = (instructors: any[]): boolean => { - if (!instructors || instructors.length === 0) return false; - return instructors.some(inst => { - if (typeof inst === 'string') { - return inst === instructorUserId; // Old format - } else if (inst && inst.userId) { - return inst.userId === instructorUserId; // New format - } - return false; - }); - }; - - // Check if instructor is already in the course's instructors array - if (!isInstructorInArray(courseData.instructors || [])) { - appLogger.log(`[COURSE-ENTRY] Instructor ${instructorUserId} not in course instructors list, adding...`); - - // Get existing instructors and convert to new format if needed - const existingInstructors = courseData.instructors || []; - const updatedInstructors = existingInstructors.map((inst: any) => { - // Convert old format to new format if needed - if (typeof inst === 'string') { - return { userId: inst, name: 'Unknown' }; // Will be updated later if needed - } - return inst; // Already in new format - }); - - // Add new instructor with name - updatedInstructors.push({ - userId: instructorUserId, - name: instructorName - }); - - await mongoDB.updateActiveCourse(courseId, { - instructors: updatedInstructors - } as any); - - appLogger.log(`[COURSE-ENTRY] Added instructor ${instructorName} (${instructorUserId}) to course's instructors list`); - } - - // Ensure instructor is enrolled in the course (add to coursesEnrolled) - if (!globalUser.coursesEnrolled.includes(courseId)) { - await mongoDB.addCourseToGlobalUser( - globalUser.puid, - courseId - ); - appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to instructor's enrolled list`); - } + const courseData = course as unknown as activeCourse; + + // Students may join by code before they appear in coursesEnrolled + if (globalUser.affiliation !== 'student' && !isCourseAccessible(courseData, globalUser)) { + return res.status(403).json({ error: 'Course membership required' }); + } + + if (isCourseStaff(courseData, globalUser) && !globalUser.coursesEnrolled.includes(courseId)) { + await mongoDB.addCourseToGlobalUser(globalUser.puid, courseId); + appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to staff enrolled list`); } // 3. Check if CourseUser exists in {courseName}_users @@ -409,7 +318,6 @@ router.post('/enter-by-code', asyncHandlerWithAuth(async (req: Request, res: Res let redirect: string; let requiresOnboarding = false; - const courseData = course as unknown as activeCourse; const isTA = isInCourseTAs(courseData, globalUser.userId); if (isTA && !globalUser.coursesEnrolled.includes(courseId)) { @@ -417,8 +325,7 @@ router.post('/enter-by-code', asyncHandlerWithAuth(async (req: Request, res: Res appLogger.log(`[COURSE-ENTRY] Added course ${courseId} to TA enrolled list`); } - const isStaff = - isCourseStaff(courseData, globalUser) || globalUser.affiliation === 'faculty'; + const isStaff = isCourseStaff(courseData, globalUser); // Sync session globalUser after enrollment mutations (coursesEnrolled drift fix). // Must precede the instructor redirect, which now reads per-user tutorial progress. diff --git a/src/routes/route-mongo.ts b/src/routes/route-mongo.ts index c459398d..b7656978 100644 --- a/src/routes/route-mongo.ts +++ b/src/routes/route-mongo.ts @@ -603,11 +603,14 @@ router.get('/course-selection', asyncHandlerWithAuth(async (req: Request, res: R const defaultPeriodId = await instance.getDefaultAcademicPeriodId(); const periods = await instance.listAcademicPeriods(); const allCourses = await instance.getAllActiveCourses(); + const platformAdmins = await instance.findAdminGlobalUsers(); + const platformAdminUserIds = new Set(platformAdmins.map((u) => u.userId)); const data = buildCourseSelectionByPeriod( periods, allCourses as activeCourse[], globalUser, - defaultPeriodId + defaultPeriodId, + platformAdminUserIds ); res.status(200).json({