Goal
クラス管理機能に「先生 → 生徒の強制退室の通知」と「席が取れない生徒 → 先生への退室リクエスト」のフロー、および「生徒のブラウザ参加状態のリセットタイミングでの自動退室」を導入し、間違った席に座った生徒の救済と、新タブやブラウザ再起動による「自分の席が押せない」状態の解消を行う。あわせて参加状態の保存先を sessionStorage → localStorage に変更してドキュメントと実装の食い違いを解消する。
Background — 現状の問題と Playwright での検証結果
問題 (1) — 先生が間違って席を選んだ生徒を退室させる UX
- 先生はすでに「Remove」ボタン(
classroom-member-remove)で kick できる
- kick 後、生徒がモーダルを次に開くと:
verifySession が 401 → clearClassroomSession() + Alert「セッションが無効になりました」+「参加しなおす」ボタンが表示される
- 画面は
student-status のままで「未提出」と表示(古い state が残る)。student-seat には自動遷移しない
- メッセージが「セッション無効」と一般的で、「先生に退室させられた」が伝わらない
- 「参加しなおす」を押せば
clearClassroomSession で seat 画面に行けるが、画面操作が必要
問題 (2) — classcode URL 再オープン時の挙動
- ストレージ実装は 実際には
sessionStorage(packages/scratch-gui/src/reducers/classroom.js:19,22,36,48)
docs/classroom/architecture.md / ui-ux.md は「localStorage」「ブラウザを閉じて再度開いても自動復帰」と書かれているがドキュメントが嘘
- Playwright で実測:
- 同タブのリロード/同タブ内のナビゲーション → セッション保持 ✓
- 新タブで classcode URL を開く → 空の sessionStorage で読み込まれ、自分の出席番号が「使用中」で押せない ✗(問題 2 の根本原因)
- ブラウザ再起動 → セッション消失(ただしサーバの席は TTL = stg 1日 / prod 30日 残る)
- さらに
classroom-modal.jsx:265-274 の classcode 処理では、別の joinCode のセッションが残っていても clearClassroomSession() だけで サーバの leaveClassroom API を呼ばない 副バグもある
Affected Files
backend (infra/smalruby-classroom/)
lib/classroom-stack.ts — ClassroomKickRequests-{stage} テーブル + GSI classroomId-seatNumber-index 追加、TTL 1h
lambda/handler.ts
handleDeleteMember を「sessionToken を残したまま kicked: true, kickedAt, kickReason をセット + ttl=now+1h」に変更
handleVerifySession を kicked === true のとき 410 Gone + {reason: 'kicked', joinCode, className, seatNumber} を返すよう変更
handleLookupClassroom の takenSeats 集計から kicked === true を除外
handleJoinClassroom の ConditionExpression を緩めて kick 済みの行は上書き可能に
- 新規
handleCreateKickRequest — POST /classrooms/lookup/kick-request (joinCode + seatNumber + 任意 reason)
- 新規
handleListKickRequests — GET /classrooms/{id}/kick-requests
- 新規
handleApproveKickRequest — POST /classrooms/{id}/kick-requests/{requestId}/approve(kick + リクエスト削除)
- 新規
handleRejectKickRequest — DELETE /classrooms/{id}/kick-requests/{requestId}(リクエスト削除のみ)
- 既存ルーティングに 4 個のエンドポイントを追加
lambda/tests/handler.integration.test.ts — 新規エンドポイント + kick retain ロジックの integration test 追加
frontend (packages/scratch-gui/)
src/reducers/classroom.js — sessionStorage → localStorage への移行 + 旧 sessionStorage 値があれば一度だけコピー後に破棄するマイグレーション。session 構造に pendingKickRequestId を追加
src/lib/classroom-api.js — 4 個の新メソッド (createKickRequest, listKickRequests, approveKickRequest, rejectKickRequest)
src/containers/classroom-modal.jsx
refreshStudentStatus の catch を「410 Gone + reason='kicked'」を区別して処理 — 保存していた joinCode を再利用して seat-lookup → seat 選択画面へ自動遷移 + 「先生によって退室させられました」のモーダル内バナー表示
- classcode URL 処理で「異なる joinCode」を検出したとき、現セッションの
leaveClassroom API を呼んでから新 joinCode で lookup
- student-seat 画面で「使用中の席」をタップ → 退室依頼ダイアログ →
createKickRequest 呼出 → seat 画面に「退室を依頼中(出席番号N)」バナー + 5 秒 polling で lookupClassroom を再取得 + ボタン disabled
src/components/classroom-modal/classroom-modal.jsx
- 既存の SeatGrid に「使用中の席タップ可能」化と「退室依頼中バナー」を追加
- kick 通知用の banner コンポーネント (
classroom-kicked-banner) を student-seat 画面の上部に表示
src/components/classroom-modal/kick-request-confirm-dialog.jsx (新規) — 「席Nの退室を先生に依頼しますか?」確認ダイアログ
src/containers/use-teacher-classrooms.js
loadClassroomDetail / refreshMembersOnly で listKickRequests も並列取得し、kickRequestsBySeat: Map<seatNumber, request> を state に保持
handleApproveKickRequest(requestId) / handleRejectKickRequest(requestId) を追加
src/components/classroom-modal/teacher-class-detail.jsx — 座席グリッドの席セルに「退室リクエスト中」バッジを追加 (data-testid: classroom-seat-kick-request-{seatNumber})
src/components/classroom-modal/teacher-member-detail.jsx — 退室リクエストが存在する席を選択時、「退室を依頼されています(メッセージ: ...)」 + 「承認して退室させる」「却下」ボタンを追加
src/locales/{ja,ja-Hira,en}.js — 新規メッセージ (gui.classroom.kicked.banner, gui.classroom.kickRequest.* 等)
docs/classroom/{ui-ux,architecture,testing,user-stories,source-code}.md — localStorage/sessionStorage の食い違い修正、新規エンドポイント、新規 data-testid、退室リクエスト UI、kick 通知 UI、新規ユーザーストーリー追記
docs/classroom/screenshots/ — 退室リクエスト画面 (生徒+先生)、kick 通知バナー、退室依頼ダイアログのスクショ追加
.claude/rules/scratch-gui/classroom.md — 新規 data-testid 概要追記
.claude/rules/scratch-gui/e2e-test.md — Classroom Modal の新規 data-testid 表を更新
Implementation Steps(TDD + Phase-by-Phase Commit)
Phase 0: ストレージ移行(localStorage 化)
Phase 1: backend — kick tombstone + verify-session 拡張
Phase 2: frontend — kick 通知 + 自動 seat 遷移
Phase 3: frontend — classcode URL 再オープン時の自動 leave
Phase 4: backend — kick request エンドポイント群
Phase 5: frontend — 生徒の退室リクエスト送信
Phase 6: frontend — 先生の退室リクエスト承認 / 却下 UI
Phase 7: Integration tests + docs + screenshots
Phase DoD: CI 完了待ち + stg デプロイ + ブラウザ確認
Definition of Done
Test Plan
| Type |
Timing |
Target |
| Unit tests (TDD) |
各 Phase の前 (RED → GREEN) |
reducer migration, kick reason ハンドリング、retainKickRequest state、hook API |
| Backend integration |
Phase 1 / 4 deploy 後 |
kick tombstone retain, kick request endpoints の挙動 |
| Frontend integration |
Phase 7 |
classroom-kick-flow.test.js / classroom-kick-request-flow.test.js |
| Browser verification |
CI green 後 |
Playwright MCP で DoD 9 項目 |
Risks & Open Questions
ユーザーインタビューで以下を確定済み:
- ストレージは localStorage に統一(マイグレーションあり)
- kick 理由は backend + frontend 両方で対応(specific メッセージ)
- 退室リクエストは 本 Issue に含める(全部入り)
- kick 後の生徒 UX は 自動で seat 選択画面に遷移 + 通知バナー
- 使用中席タップ → 「退室を依頼」ボタン + 確認ダイアログ
- 先生側 UI は 座席グリッドのバッジ + member-detail で承認/却下
- abuse 防止 → 規制なし(複数件のリクエストを許可)
- リクエスト TTL は 1 時間で自動期切れ + 承認/却下時に削除
- 生徒の送信後挙動は 1件だけ制限、ボタン disabled、polling で反映
- classcode URL 自動 leave は 異なる joinCode 検出時のみ
残る注意点:
- Backend のスキーマ変更 は既存本番データへの影響なし(新カラムは optional、FilterExpression で対応)
- stg と prod の TTL 差 (1日 vs 30日) で kick request の TTL は 1h 固定、stg では Membership 自体が 1日で消えるが kick tombstone 1h と並走し問題なし
- Polling 5 秒間隔で /classrooms/lookup を回す → join rate limit (stg: 100 req/30秒) に近づくが 5秒×1リクエスト = 30秒で 6 回なので余裕あり
- sessionStorage → localStorage 移行 で複数タブで別生徒が参加していると後勝ちで上書き → 同一ブラウザで複数アカウント参加は仕様外。「複数の sessionStorage がある場合は 1 つだけ採用」と本 Issue で確認済み
Goal
クラス管理機能に「先生 → 生徒の強制退室の通知」と「席が取れない生徒 → 先生への退室リクエスト」のフロー、および「生徒のブラウザ参加状態のリセットタイミングでの自動退室」を導入し、間違った席に座った生徒の救済と、新タブやブラウザ再起動による「自分の席が押せない」状態の解消を行う。あわせて参加状態の保存先を
sessionStorage→localStorageに変更してドキュメントと実装の食い違いを解消する。Background — 現状の問題と Playwright での検証結果
問題 (1) — 先生が間違って席を選んだ生徒を退室させる UX
classroom-member-remove)で kick できるverifySessionが 401 →clearClassroomSession()+ Alert「セッションが無効になりました」+「参加しなおす」ボタンが表示されるstudent-statusのままで「未提出」と表示(古い state が残る)。student-seatには自動遷移しないclearClassroomSessionで seat 画面に行けるが、画面操作が必要問題 (2) — classcode URL 再オープン時の挙動
sessionStorage(packages/scratch-gui/src/reducers/classroom.js:19,22,36,48)docs/classroom/architecture.md/ui-ux.mdは「localStorage」「ブラウザを閉じて再度開いても自動復帰」と書かれているがドキュメントが嘘classroom-modal.jsx:265-274の classcode 処理では、別の joinCode のセッションが残っていてもclearClassroomSession()だけで サーバのleaveClassroomAPI を呼ばない 副バグもあるAffected Files
backend (
infra/smalruby-classroom/)lib/classroom-stack.ts—ClassroomKickRequests-{stage}テーブル + GSIclassroomId-seatNumber-index追加、TTL 1hlambda/handler.tshandleDeleteMemberを「sessionToken を残したままkicked: true, kickedAt, kickReasonをセット + ttl=now+1h」に変更handleVerifySessionをkicked === trueのとき 410 Gone +{reason: 'kicked', joinCode, className, seatNumber}を返すよう変更handleLookupClassroomの takenSeats 集計からkicked === trueを除外handleJoinClassroomの ConditionExpression を緩めて kick 済みの行は上書き可能にhandleCreateKickRequest—POST /classrooms/lookup/kick-request(joinCode + seatNumber + 任意 reason)handleListKickRequests—GET /classrooms/{id}/kick-requestshandleApproveKickRequest—POST /classrooms/{id}/kick-requests/{requestId}/approve(kick + リクエスト削除)handleRejectKickRequest—DELETE /classrooms/{id}/kick-requests/{requestId}(リクエスト削除のみ)lambda/tests/handler.integration.test.ts— 新規エンドポイント + kick retain ロジックの integration test 追加frontend (
packages/scratch-gui/)src/reducers/classroom.js—sessionStorage→localStorageへの移行 + 旧 sessionStorage 値があれば一度だけコピー後に破棄するマイグレーション。session 構造にpendingKickRequestIdを追加src/lib/classroom-api.js— 4 個の新メソッド (createKickRequest,listKickRequests,approveKickRequest,rejectKickRequest)src/containers/classroom-modal.jsxrefreshStudentStatusの catch を「410 Gone + reason='kicked'」を区別して処理 — 保存していたjoinCodeを再利用して seat-lookup → seat 選択画面へ自動遷移 + 「先生によって退室させられました」のモーダル内バナー表示leaveClassroomAPI を呼んでから新 joinCode で lookupcreateKickRequest呼出 → seat 画面に「退室を依頼中(出席番号N)」バナー + 5 秒 polling でlookupClassroomを再取得 + ボタン disabledsrc/components/classroom-modal/classroom-modal.jsxclassroom-kicked-banner) を student-seat 画面の上部に表示src/components/classroom-modal/kick-request-confirm-dialog.jsx(新規) — 「席Nの退室を先生に依頼しますか?」確認ダイアログsrc/containers/use-teacher-classrooms.jsloadClassroomDetail/refreshMembersOnlyでlistKickRequestsも並列取得し、kickRequestsBySeat: Map<seatNumber, request>を state に保持handleApproveKickRequest(requestId)/handleRejectKickRequest(requestId)を追加src/components/classroom-modal/teacher-class-detail.jsx— 座席グリッドの席セルに「退室リクエスト中」バッジを追加 (data-testid:classroom-seat-kick-request-{seatNumber})src/components/classroom-modal/teacher-member-detail.jsx— 退室リクエストが存在する席を選択時、「退室を依頼されています(メッセージ: ...)」+ 「承認して退室させる」「却下」ボタンを追加src/locales/{ja,ja-Hira,en}.js— 新規メッセージ (gui.classroom.kicked.banner,gui.classroom.kickRequest.*等)docs/classroom/{ui-ux,architecture,testing,user-stories,source-code}.md—localStorage/sessionStorageの食い違い修正、新規エンドポイント、新規 data-testid、退室リクエスト UI、kick 通知 UI、新規ユーザーストーリー追記docs/classroom/screenshots/— 退室リクエスト画面 (生徒+先生)、kick 通知バナー、退室依頼ダイアログのスクショ追加.claude/rules/scratch-gui/classroom.md— 新規 data-testid 概要追記.claude/rules/scratch-gui/e2e-test.md— Classroom Modal の新規 data-testid 表を更新Implementation Steps(TDD + Phase-by-Phase Commit)
Phase 0: ストレージ移行(localStorage 化)
[RED]test/unit/reducers/classroom-reducer.test.jsを更新 —localStorageを見るように、sessionStorage→localStorage マイグレーション込みで失敗するテストを追加[GREEN]src/reducers/classroom.jsを更新 —loadSession/saveSession/clearStoredSessionをlocalStorage経由に変更、旧 sessionStorage 値があれば 1 回だけコピー後に削除[PASS]lint + 該当 unit test[COMMIT & PUSH]fix(classroom): migrate session persistence from sessionStorage to localStorage[MAKE PR]Implementation Steps を checkbox 化して PR 本文に貼るPhase 1: backend — kick tombstone + verify-session 拡張
[RED]lambda/tests/handler.integration.test.tsに「kick 後 → verify-session 410 reason='kicked'」のテスト追加[GREEN]handler.tsのhandleDeleteMember/handleVerifySession/handleLookupClassroom/handleJoinClassroomを改修[PASS]docker compose run --rm -w /app/infra/smalruby-classroom infra npm test[stg deploy]cdk deploy --context stage=stg→npm run test:integrationpass[COMMIT & PUSH]feat(classroom): mark kicked members and surface reason via verify-session[UPDATE PR]Phase 1 checkbox を checkPhase 2: frontend — kick 通知 + 自動 seat 遷移
[RED]test/unit/reducers/classroom-reducer.test.jsに kick 由来の session 無効後の state ハンドリングのテスト追加[GREEN]classroom-modal.jsxのrefreshStudentStatuscatch を改修、kicked-banner を表示、seat 画面に自動遷移[PASS]lint + 該当 unit test[COMMIT & PUSH]feat(classroom): show kick banner and auto-navigate to seat selection on forced leave[UPDATE PR]Phase 2 checkbox を checkPhase 3: frontend — classcode URL 再オープン時の自動 leave
[RED]test/integration/classroom-classcode-reopen.test.js(新規) — useEffect でサーバ leave 呼出を mock 検証[GREEN]classroom-modal.jsxの classcode 処理にawait classroomAPI.leaveClassroom()を挿入(異なる joinCode 検出時のみ)[PASS]lint + 該当 integration test[COMMIT & PUSH]fix(classroom): leave old session on server when classcode URL points to different classroom[UPDATE PR]Phase 3 checkbox を checkPhase 4: backend — kick request エンドポイント群
[RED]integration test 4 個 — create / list / approve / reject[GREEN]classroom-stack.tsに新テーブル + GSI、handler.tsに 4 ハンドラ + ルーティング追加[PASS]unit (lint) +cdk synth通過[stg deploy + integration test][COMMIT & PUSH]feat(classroom): kick request endpoints for student-initiated seat reclaim[UPDATE PR]Phase 4 checkbox を checkPhase 5: frontend — 生徒の退室リクエスト送信
[RED]test/unit/containers/classroom-modal-kick-request.test.jsx新規 — 使用中の席タップ → ダイアログ → API 呼出 →pendingKickRequestIdが localStorage に永続化されることを検証[GREEN]classroom-api.js拡張、classroom-modal.jsxに席タップハンドラ + ダイアログ + polling、kick-request-confirm-dialog.jsx新規、classroom-modal.jsx (component)の seat grid に「使用中タップ可能」モード追加[PASS]lint + 該当 unit test[COMMIT & PUSH]feat(classroom): allow students to request seat reclaim from teacher[UPDATE PR]Phase 5 checkbox を checkPhase 6: frontend — 先生の退室リクエスト承認 / 却下 UI
[RED]test/unit/containers/use-teacher-classrooms-kick.test.js新規 — listKickRequests を hook が取得する/承認時に handleDeleteMember 経由で member が消えることをテスト[GREEN]use-teacher-classrooms.js拡張、teacher-class-detail.jsxの seat cell にバッジ、teacher-member-detail.jsxに承認 / 却下ボタン[PASS]lint + 該当 unit test[COMMIT & PUSH]feat(classroom): teacher UI to approve or reject kick requests[UPDATE PR]Phase 6 checkbox を checkPhase 7: Integration tests + docs + screenshots
test/integration/classroom-kick-flow.test.js新規 — 先生 kick → 生徒モーダル open → 自動 seat 遷移 + バナーの end-to-endtest/integration/classroom-kick-request-flow.test.js新規 — 生徒 lookup → 使用中タップ → 退室依頼 → 先生承認 → 生徒 polling で席空き反映docs/classroom/*.md更新、screenshots 撮影 (tmp/経由)[PASS]lint + 該当テスト[COMMIT & PUSH]test(classroom): add integration tests for kick flow and kick request flow+ 別 commit でdocs(classroom): document kick notification and kick request flow[UPDATE PR]Phase 7 checkbox を checkPhase DoD: CI 完了待ち + stg デプロイ + ブラウザ確認
gh pr checks <PR番号> --watchで CI 完了待ちDefinition of Done
npm run lintで zero warnings)infra/smalruby-classroomのnpm run test:integrationpassRemoveボタンで kick → 生徒のタブで modal を開くと「先生によって退室させられました」バナー + 自動で seat 選択画面へ。同じ席を選び直して再参加できるbuild:devではなくbuild) で同じシナリオが動く(ターミナル変換忘れチェック)Test Plan
Risks & Open Questions
ユーザーインタビューで以下を確定済み:
残る注意点: