Skip to content

feat(classroom): kick notification, classcode URL auto-leave, and kick request flow #692

@takaokouji

Description

@takaokouji

Goal

クラス管理機能に「先生 → 生徒の強制退室の通知」と「席が取れない生徒 → 先生への退室リクエスト」のフロー、および「生徒のブラウザ参加状態のリセットタイミングでの自動退室」を導入し、間違った席に座った生徒の救済と、新タブやブラウザ再起動による「自分の席が押せない」状態の解消を行う。あわせて参加状態の保存先を sessionStoragelocalStorage に変更してドキュメントと実装の食い違いを解消する。

Background — 現状の問題と Playwright での検証結果

問題 (1) — 先生が間違って席を選んだ生徒を退室させる UX

  • 先生はすでに「Remove」ボタン(classroom-member-remove)で kick できる
  • kick 後、生徒がモーダルを次に開くと:
    • verifySession が 401 → clearClassroomSession() + Alert「セッションが無効になりました」+「参加しなおす」ボタンが表示される
    • 画面は student-status のままで「未提出」と表示(古い state が残る)。student-seat には自動遷移しない
    • メッセージが「セッション無効」と一般的で、「先生に退室させられた」が伝わらない
    • 「参加しなおす」を押せば clearClassroomSession で seat 画面に行けるが、画面操作が必要

問題 (2) — classcode URL 再オープン時の挙動

  • ストレージ実装は 実際には sessionStoragepackages/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.tsClassroomKickRequests-{stage} テーブル + GSI classroomId-seatNumber-index 追加、TTL 1h
  • lambda/handler.ts
    • handleDeleteMember を「sessionToken を残したまま kicked: true, kickedAt, kickReason をセット + ttl=now+1h」に変更
    • handleVerifySessionkicked === true のとき 410 Gone + {reason: 'kicked', joinCode, className, seatNumber} を返すよう変更
    • handleLookupClassroom の takenSeats 集計から kicked === true を除外
    • handleJoinClassroom の ConditionExpression を緩めて kick 済みの行は上書き可能に
    • 新規 handleCreateKickRequestPOST /classrooms/lookup/kick-request (joinCode + seatNumber + 任意 reason)
    • 新規 handleListKickRequestsGET /classrooms/{id}/kick-requests
    • 新規 handleApproveKickRequestPOST /classrooms/{id}/kick-requests/{requestId}/approve(kick + リクエスト削除)
    • 新規 handleRejectKickRequestDELETE /classrooms/{id}/kick-requests/{requestId}(リクエスト削除のみ)
    • 既存ルーティングに 4 個のエンドポイントを追加
  • lambda/tests/handler.integration.test.ts — 新規エンドポイント + kick retain ロジックの integration test 追加

frontend (packages/scratch-gui/)

  • src/reducers/classroom.jssessionStoragelocalStorage への移行 + 旧 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 / refreshMembersOnlylistKickRequests も並列取得し、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}.mdlocalStorage/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/clearStoredSessionlocalStorage 経由に変更、旧 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.tshandleDeleteMember / handleVerifySession / handleLookupClassroom / handleJoinClassroom を改修
  • [PASS] docker compose run --rm -w /app/infra/smalruby-classroom infra npm test
  • [stg deploy] cdk deploy --context stage=stgnpm run test:integration pass
  • [COMMIT & PUSH] feat(classroom): mark kicked members and surface reason via verify-session
  • [UPDATE PR] Phase 1 checkbox を check

Phase 2: frontend — kick 通知 + 自動 seat 遷移

  • [RED] test/unit/reducers/classroom-reducer.test.js に kick 由来の session 無効後の state ハンドリングのテスト追加
  • [GREEN] classroom-modal.jsxrefreshStudentStatus catch を改修、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 を check

Phase 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 を check

Phase 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 を check

Phase 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 を check

Phase 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 を check

Phase 7: Integration tests + docs + screenshots

  • test/integration/classroom-kick-flow.test.js 新規 — 先生 kick → 生徒モーダル open → 自動 seat 遷移 + バナーの end-to-end
  • test/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 を check

Phase DoD: CI 完了待ち + stg デプロイ + ブラウザ確認

  • gh pr checks <PR番号> --watch で CI 完了待ち
  • frontend のプレビュー URL (PR コメント) で Playwright MCP 確認

Definition of Done

  • 全 Phase の unit test pass
  • integration test (frontend / infra) pass
  • lint pass (npm run lint で zero warnings)
  • CI green
  • stg backend デプロイ済み、infra/smalruby-classroomnpm run test:integration pass
  • frontend のプレビュー URL で Playwright MCP DoD 確認:
    • (1a) 同タブリロード → localStorage に session 残る、student-status 画面へ
    • (1b) 新タブで同じ classcode URL を開く → localStorage から session を読んで student-status 画面へ(席選択にならない)
    • (2) 先生が Remove ボタンで kick → 生徒のタブで modal を開くと「先生によって退室させられました」バナー + 自動で seat 選択画面へ。同じ席を選び直して再参加できる
    • (3) 異なる classcode URL を開く → 古いセッションがサーバ上 leave される(先生画面で席が空く) + 新クラスの seat 選択へ遷移
    • (4) 使用中の席をタップ → 「退室を依頼」ダイアログ → 送信 → seat 画面に「退室を依頼中(出席番号N)」バナー、ボタン disabled、polling 動作
    • (5) 先生画面で対応席に「退室リクエスト」バッジ表示 + member-detail で「承認して退室させる」を押すと、生徒の seat 画面が polling で席を開放し、生徒がその席を選べる
    • (6) 先生が「却下」を押すと、リクエストが消え、生徒の依頼ボタンが再び有効に
    • (7) iPad 横 (1024×768) と iPhone 縦 (375×667) で kick banner / 退室依頼ダイアログ / 先生 member-detail の追加 UI がレイアウト崩れせず操作可能
    • (8) 互換性: 旧 sessionStorage 値を localStorage に手動でセットした状態でアプリを開いて、移行が走り localStorage に転記 + sessionStorage がクリアされる
    • (9) prod 相当 build (build:dev ではなく build) で同じシナリオが動く(ターミナル変換忘れチェック)

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 で確認済み

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions