Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions documents/ENDPOINT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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 |
Expand Down
134 changes: 111 additions & 23 deletions public/scripts/entry/admin-course-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<activeCourse, 'instructors'> & {
instructorDisplay?: string;
instructors?: CourseStaffMember[];
};

interface AdminPeriodSection extends AcademicPeriodDocument {
courseCount: number;
courses: (activeCourse & { instructorDisplay?: string })[];
courses: AdminCourseRow[];
}

interface AdminCourseSelectionPayload {
Expand Down Expand Up @@ -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 `
<div class="workspace-row admin-course-row" data-course-id="${course.id}" data-period-id="${periodId}">
Expand Down Expand Up @@ -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<void> {
if (!pageData) {
return;
Expand Down Expand Up @@ -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<typeof createCourseStaffPicker> | 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';
Expand All @@ -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',
Expand All @@ -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<string, unknown> = { 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) {
Expand All @@ -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';
Expand Down
10 changes: 0 additions & 10 deletions public/scripts/entry/course-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand All @@ -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 `
Expand Down
Loading