feat(fe): add student roster and enrollment verification to course join - #3652
feat(fe): add student roster and enrollment verification to course join#3652jinukkkim wants to merge 6 commits into
Conversation
Frontend-only implementation of student roster (name+studentId) editing in Create/Edit/Duplicate Course, and the Verify Your Enrollment flow in RegisterCourseButton. Assumes a backend contract (GroupWhitelist.name, createWhitelist names arg, getWhitelistEntries query, join stage/studentId params) that isn't implemented yet. Checkpoint before temporarily adding matching backend code locally to verify end-to-end, to be reverted after.
강의 멤버 목록에서 계정 이름 대신 교수님이 로스터에 입력한 이름을 우선 표시한다. getWhitelistEntries를 학번 기준으로 매칭하고, 이름이 없거나 조회가 실패하면 기존 계정 이름으로 fallback한다.
로컬 e2e 검증 중 발견한 버그를 수정한다. valibotResolver가 courseSchema에 없는 roster 필드를 제출 데이터에서 지워버려 Create/Edit Course에서 로스터가 누락되는 문제를 getValues로 우회한다. Verify Your Enrollment 모달이 size="sm"일 때 시도 횟수 안내문이 잘려 보이지 않던 문제와 Student Roster의 Clear all이 초기 행 수(5개)가 아닌 1개로 초기화되던 문제도 함께 수정한다. RegisterCourseButton은 실제 API 에러 응답(409 already joined, 404 group not found, 403 not authorized)에 맞춰 안내 문구를 세분화한다.
There was a problem hiding this comment.
Code Review
This pull request introduces a student roster (whitelist) feature for courses, allowing instructors to upload a list of student IDs and names during course creation, duplication, and editing. It also refactors the course registration button into a multi-step dialog that verifies student IDs against the roster. The review feedback highlights several key improvement opportunities: de-duplicating student IDs in the roster modal before saving, using unique keys instead of array indices for dynamic table rows to prevent rendering bugs, and adding client-side validation to prevent sending empty or whitespace-only invitation codes and student IDs to the server.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| onClick: () => { | ||
| onSave(draft.filter((row) => row.studentId.trim())) | ||
| onOpenChange(false) | ||
| } |
There was a problem hiding this comment.
복사-붙여넣기 과정에서 실수로 중복된 학번(Student ID)이 포함될 수 있습니다. 중복된 학번이 백엔드로 전송되면 데이터 무결성 제약 조건 등으로 인해 에러가 발생할 수 있으므로, 저장 시점에 학번을 기준으로 중복을 제거(De-duplicate)하는 로직을 추가하는 것이 안전합니다.
onClick: () => {
const seen = new Set<string>()
const uniqueRows = draft.filter((row) => {
const trimmedId = row.studentId.trim()
if (!trimmedId || seen.has(trimmedId)) {
return false
}
seen.add(trimmedId)
return true
})
onSave(uniqueRows)
onOpenChange(false)
}
| {draft.map((row, rowIndex) => ( | ||
| <tr key={rowIndex} className="border-t border-[#eeeef1]"> |
| const handleVerifyEnrollment = async () => { | ||
| if (!foundCourseId) { | ||
| return | ||
| } | ||
| try { | ||
| await safeFetcherWithAuth.post(`course/${foundCourse?.id}/join`, { | ||
| searchParams: { invitation: invitationCode } | ||
| }) | ||
| queryClient.invalidateQueries({ queryKey: ['joinedCourses'] }) | ||
| toast.success('Successfully registered course.') | ||
| setIsVerifyDialogOpen(false) | ||
| setIsRegisterDialogOpen(false) | ||
| await joinCourse(foundCourseId, 'manual', manualStudentId) |
There was a problem hiding this comment.
manualStudentId가 비어있거나 공백만 있는 경우에도 서버에 검증 요청을 보내게 됩니다. 불필요한 API 호출을 방지하고 사용자에게 즉각적인 피드백을 주기 위해, 요청을 보내기 전에 클라이언트 측에서 유효성 검사(예: 빈 값 체크)를 수행하는 것이 좋습니다.
const handleVerifyEnrollment = async () => {
if (!foundCourseId) {
return
}
const trimmedSid = manualStudentId.trim()
if (!trimmedSid) {
setSidError('Please enter your Student ID.')
setSidShakeKey((k) => k + 1)
return
}
try {
await joinCourse(foundCourseId, 'manual', trimmedSid)
| const handleSubmitCode = async () => { | ||
| let groupId: number | ||
| try { | ||
| const data = await safeFetcherWithAuth | ||
| .get('course/invite', { | ||
| searchParams: { invitation: invitationCode } | ||
| }) | ||
| .get('course/invite', { searchParams: { invitation: invitationCode } }) |
There was a problem hiding this comment.
invitationCode가 비어있거나 공백만 있는 경우에도 서버에 요청을 보내게 됩니다. 불필요한 API 호출을 방지하기 위해, 요청을 보내기 전에 클라이언트 측에서 유효성 검사를 수행하는 것이 좋습니다.
const handleSubmitCode = async () => {
const trimmedCode = invitationCode.trim()
if (!trimmedCode) {
setCodeError('Please enter an invitation code.')
setCodeShakeKey((k) => k + 1)
return
}
let groupId: number
try {
const data = await safeFetcherWithAuth
.get('course/invite', { searchParams: { invitation: trimmedCode } })
|
❗ Syncing Preview App Failed Application: |
|
학번 중복 이슈가 조금 걸리긴하는데 그 부분만 빼고는 괜찮아보입니다~ |
백엔드가 아직 roster/enrollment 검증 계약을 구현하지 않아 PR preview에서 기능을 확인할 수 없었다. 리뷰어가 preview로 플로우를 볼 수 있도록 기존 코드를 수정하지 않고 mock 파일만 추가한다. - mock-schema.graphql: codegen이 검증에 사용할 스키마 stub (GroupWhitelist.name, getWhitelistEntries, createWhitelist의 names 인자). codegen.ts의 schema를 배열로 바꿔 실제 schema.gql과 병합한다. 이것만으로는 빌드/타입체크만 통과하고 런타임 동작은 하지 않는다. - mocks/enrollment-demo-*: MSW로 join 요청과 whitelist GraphQL만 가로채 실제 응답 형태(WHITELIST_VIOLATION, ENROLLMENT_LOCKED, attemptsRemaining)를 흉내낸다. 나머지 요청은 모두 실제 백엔드로 통과시킨다. 계정 전환에도 roster가 유지되도록 localStorage에 저장하고, lockout은 JWT의 userId로 분리해 한 유저의 실패가 다른 유저를 잠그지 않게 한다. - mockServiceWorker.js는 msw가 생성한 vendor 파일이라 prettier/eslint ignore에 추가한다. 주의: instrumentation-client.ts가 조건 없이 worker를 시작하므로 이 상태로 머지하면 실서비스에서도 mock이 동작한다. 백엔드 구현이 머지된 뒤 mock 파일들을 삭제하고 프론트를 다시 연결해야 한다.
1aee42d to
8e5d524
Compare
|
✅ Syncing Preview App Succeeded Application: |
next-pwa가 scope '/'에 /sw.js를 등록하는데, MSW의 /mockServiceWorker.js도 같은 scope가 필요하다. 서비스워커는 한 scope에 하나만 페이지를 제어할 수 있어서 프로덕션 빌드에서는 PWA 워커가 이겨 목이 요청을 가로채지 못했다. next-pwa의 disable 옵션이 development에서만 켜져 있어 로컬에서는 정상 동작하고 preview에서만 실패했다. 목 제거 시 disable을 원래의 NODE_ENV 검사로 되돌려야 한다.
|
✅ Syncing Preview App Succeeded Application: |
4540bb7d에서 공용 shadcn dialog의 닫기 버튼 크기를 조정했으나(버튼 h-5→h-6, X 아이콘 h-5→h-4) 이는 이번 기능에 필요한 변경이 아니었다. 로스터 모달과 수강 인증 모달은 각각 자체 닫기 버튼을 쓰거나 hideCloseButton으로 공용 버튼을 끄고 있어 실제 사용처가 없다. 이 파일은 앱 전체의 모든 Dialog가 사용하므로 PR 범위를 벗어난 시각적 변경을 되돌린다. 디자인 통일이 필요하면 별도 PR로 진행한다.
|
✅ Syncing Preview App Succeeded Application: |
|
❗ Syncing Preview App Failed Application: |
Description
강의 입장 보안 개선 (교수님 요청: 학번만으로는 계정 이름이 영문으로 표시되어 누구인지 알아보기 어려움).
프론트엔드 전용 PR. 백엔드가 아직 구현되지 않아, 리뷰어가 preview에서 흐름을 직접 확인할 수 있도록 임시 mock을 함께 포함했습니다. 자세한 내용은 아래 "Preview 데모용 임시 mock" 참고.
구현 내용
1. Student Roster 입력 (교수/관리자)
Students섹션과 Student Roster 팝업 추가 (StudentRosterModal.tsx, 신규).createWhitelist(groupId, studentIds, names)로 전송..xlsx업로드 방식은 대체하지 않고 Member 탭의 초대 흐름(InviteByCode)에 그대로 유지됩니다. 즉 현재 화이트리스트 입력 경로가 두 개입니다 (신규 로스터 팝업 = 학번+이름, 기존 업로드 = 학번만). 통합 여부는 별도 논의가 필요합니다.2. 멤버 목록에 로스터 이름 표시
GroupTable.tsx).getWhitelistEntries를 별도 쿼리로 호출해 프론트에서 학번 기준으로 병합합니다. 이 쿼리는errorPolicy: 'ignore'로 호출하므로, 실패하거나 백엔드가 아직 지원하지 않으면 전원 계정 이름으로 표시될 뿐 목록 자체는 깨지지 않습니다.3. 수강생 측 "Verify Your Enrollment" 흐름 (
RegisterCourseButton.tsx)stage=auto) → 화이트리스트 불일치면 학번 직접 입력 화면(stage=manual)으로 전환 → 성공/잠금 화면.attemptsRemaining을 문구에 그대로 출력, 마지막 1회일 때만 별도 문구).reason없는 응답은 409(이미 가입), 404(강의 없음), 403(권한 없음), 기타로 나눠 일반 에러 화면 처리.공용 컴포넌트 변경 (리뷰 시 확인 필요)
로스터 모달 레이아웃을 맞추면서 다른 화면에도 영향을 주는 파일 두 개를 건드렸습니다:
components/Modal.tsxmin-h-0추가Modal을 쓰는 모든 화면app/globals.cssshakekeyframe 추가백엔드에 필요한 구현
이 PR의 프론트엔드 코드가 전제하고 있는 계약입니다.
GroupWhitelist에 nullablename컬럼 추가createWhitelist(groupId, studentIds, names)—names: [String!]인자 추가 (studentIds와 같은 순서, nullable)getWhitelistEntries(groupId): [GroupWhitelist!]!쿼리 추가 (Edit Course 로스터 prefill + 멤버 목록 이름 병합용)POST /course/:groupId/join에stage(auto/manual),studentId쿼리 파라미터 추가{ reason: 'WHITELIST_VIOLATION', attemptsRemaining?: number }{ reason: 'ENROLLMENT_LOCKED' }getGroupMembers응답에 로스터 이름을 직접 병합해주면, 프론트의 별도 쿼리 + 클라이언트 병합을 제거할 수 있습니다.Preview 데모용 임시 mock
백엔드가 없어 preview에서 흐름을 볼 수 없었기 때문에, 기존 기능 코드는 건드리지 않고 mock 파일만 추가했습니다.
mock-schema.graphql— codegen이 검증에 사용할 스키마 stub.codegen.ts의schema를 배열로 바꿔 실제schema.gql과 병합합니다. 이것만으로는 빌드/타입체크만 통과하고 런타임 동작은 하지 않습니다.mocks/enrollment-demo-*— MSW로 join 요청과 whitelist GraphQL 3개만 가로채 실제 응답 형태를 흉내냅니다. 나머지 요청은 전부 실제 백엔드로 통과합니다. 계정 전환에도 유지되도록 roster를 localStorage에 저장하고, 잠금은 JWT의 userId로 분리해 한 사용자의 실패가 다른 사용자를 잠그지 않습니다.next.config.ts에서 next-pwa를 껐습니다 — PWA가 scope/에/sw.js를 등록하는데 MSW의/mockServiceWorker.js도 같은 scope가 필요해, 프로덕션 빌드에서 PWA 워커가 이기면 mock이 동작하지 않기 때문입니다.preview 확인 방법
백엔드 merge 후 해야 할 일
main에 merge되고apps/backend/schema.gql이 갱신됐는지 확인apps/frontend/mock-schema.graphqlapps/frontend/mocks/enrollment-demo-store.tsapps/frontend/mocks/enrollment-demo-handlers.tsapps/frontend/mocks/enrollment-demo-worker.tsapps/frontend/instrumentation-client.tsapps/frontend/public/mockServiceWorker.jsapps/frontend/codegen.ts—schema를'../backend/schema.gql'문자열로 되돌리기apps/frontend/next.config.ts—disable: true→disable: process.env.NODE_ENV === 'development'apps/frontend/package.json—msw.workerDirectory필드 제거.prettierignore/eslint.config.mjs—mockServiceWorker.jsignore 항목 제거msw자체는 원래 vitest용 devDependency이므로 제거하지 말 것)stage=auto) → 학번 입력(stage=manual) 전환이 실제 403reason으로 동작하는지attemptsRemaining과 맞는지, 잠금 후 재시도가 막히는지getGroupMembers에 이름을 병합해준다면GroupTable.tsx의 별도 쿼리·클라이언트 병합 제거Modal.tsx의min-h-0변경이 다른 화면에 회귀를 만들지 않았는지 확인Additional context
mock-schema.graphql덕분에 통과합니다. 이 파일을 지우면 백엔드 스키마가 갱신되기 전까지 다시 실패합니다.Before submitting the PR, please make sure you do the following
fixes #123).Closes TAS-2787