diff --git a/.claude/rules/infra/smalruby-classroom.md b/.claude/rules/infra/smalruby-classroom.md index 9bd5785cd69..313907afbfd 100644 --- a/.claude/rules/infra/smalruby-classroom.md +++ b/.claude/rules/infra/smalruby-classroom.md @@ -27,12 +27,16 @@ CDK project for the Classroom service (API Gateway + Lambda + DynamoDB + S3). `X-Google-Access-Token` を含む)。stage スロットリングに加え、`/classrooms/join` と `/classrooms/lookup` はルート個別に厳しめのスロットリングを設定 - **Lambda (Node.js 22)**: 単一ハンドラー (`lambda/handler.ts`) に全ビジネスロジック -- **DynamoDB (4 テーブル)**: +- **DynamoDB (8 テーブル)**: - `Classrooms{suffix}` — GSI `joinCode-index` / `teacherSub-index` - `ClassroomMemberships{suffix}` — GSI `sessionToken-index` - `ClassroomSubmissions{suffix}` — GSI `classroomId-memberId-index` - `ClassroomKickRequests{suffix}` — GSI `classroomId-seatNumber-index`(退室依頼 #692) - - すべて TTL `ttl`・**RemovalPolicy: DESTROY** + - `ClassroomGroups{suffix}` — GSI `teacherSub-index`(クラス=学級、長期 TTL 400日) + - `SharedAssignments{suffix}` — GSI `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(みんなの課題 #1066。**TTL なし・prod RETAIN + PITR**) + - `SharedAssignmentReports{suffix}` — 通報(TTL 90日) + - `ClassroomNotifications{suffix}` — お知らせ #1111(PK teacherSub / SK notificationId。**書き手は admin スタックのみ**・この Lambda は Query/UpdateItem だけ grant) + - 上記注記のないものは TTL `ttl`・**RemovalPolicy: DESTROY** - **S3** `smalruby-classroom-submissions{suffix}`: 提出ファイル (project.sb3, thumbnail.png, screenshots)。lifecycle 期限 = `CLASSROOM_TTL_DAYS` - **認証**: Google / Microsoft ID Token(`iss` 自動判別 + JWKS 検証。bug-report と同型)。 diff --git a/docs/admin/README.md b/docs/admin/README.md index 42ac0624d66..b240f5e89b3 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -16,8 +16,8 @@ ### セクション(3 ドメイン) -1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit) -2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継) +1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / **限定公開タブ**(推薦候補の母集団・#1110)/ 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)/ **推薦する・推薦を取り消す**(#1110。2 段階確認・audit。推薦すると作成した先生のお知らせセンター #1111 に通知が届き、限定公開を全体公開に広げる動線につながる。取り消しは通知なし) +2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。候補行には**推奨済みバッジ**)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)。クラス詳細には**先生へのお知らせ送信**(EPIC #1111)があり、タイトル + 本文を二段階確認で `POST /admin/notifications` へ送ると、その課題を作成した先生のクラス管理画面右上「お知らせ」🔔 に届く(宛先 teacherSub はサーバー側で classroomId から解決し SPA には出さない。書き込み先 `ClassroomNotifications` テーブルは名前規約 import + write-only grant で、この面が単一の書き手)。さらに**みんなの課題への共有推奨**(EPIC #1106)があり、二段階確認で `POST /admin/classrooms/{id}/recommend-sharing` を実行すると先生へお知らせ(`share_suggestion`)が届き、先生の課題詳細に「この課題、みんなの課題に共有しませんか?」バナーが出る(公開は CC BY 同意を伴う先生本人の共有フローのみ — 代理公開はしない。取り消しは通知なし・audit のみ) 3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる ## 認証・認可モデル(要点) @@ -90,3 +90,6 @@ cd tools/playwright-verify && node verify-admin.mjs | `screenshots/0103-classrooms.png` | クラス・課題: 検索一覧 | | `screenshots/0104-restore-plan.png` | 期限切れ復元: dry-run プラン | | `screenshots/0105-bug-reports.png` | バグ報告: 閲覧一覧 | +| `screenshots/0106-classroom-notify.png` | クラス詳細: 先生へのお知らせ送信(EPIC #1111) | +| `screenshots/0107-shared-recommend.png` | みんなの課題: 限定公開の詳細と推薦の確認(EPIC #1110) | +| `screenshots/0108-recommend-sharing.png` | クラス詳細: 共有推奨の確認(EPIC #1106) | diff --git a/docs/admin/screenshots/0106-classroom-notify.png b/docs/admin/screenshots/0106-classroom-notify.png new file mode 100644 index 00000000000..37d13980c71 Binary files /dev/null and b/docs/admin/screenshots/0106-classroom-notify.png differ diff --git a/docs/admin/screenshots/0107-shared-recommend.png b/docs/admin/screenshots/0107-shared-recommend.png new file mode 100644 index 00000000000..460972ba90f Binary files /dev/null and b/docs/admin/screenshots/0107-shared-recommend.png differ diff --git a/docs/admin/screenshots/0108-recommend-sharing.png b/docs/admin/screenshots/0108-recommend-sharing.png new file mode 100644 index 00000000000..0c1fd913d12 Binary files /dev/null and b/docs/admin/screenshots/0108-recommend-sharing.png differ diff --git a/docs/assignment-sharing/README.md b/docs/assignment-sharing/README.md index 8305a638883..78edbc848ab 100644 --- a/docs/assignment-sharing/README.md +++ b/docs/assignment-sharing/README.md @@ -51,6 +51,27 @@ ![取り込み完了](screenshots/0104-imported.png) +## 限定公開と運営の推薦(#1109 / #1110) + +共有時に**公開範囲**を選べる: `public`(みんなの課題カタログ)/ `limited`(**合言葉限定公開** — 参加コード同型の合言葉を知っている人だけが取り込める内輪公開。CC BY 同意・属性・著者名は任意)。 + +```text +限定公開(合言葉・内輪)→ Admin が把握(限定公開タブ)→ 推薦 → 先生が全体公開に広げる +``` + +- **推薦(Admin)**: 運営が Admin SPA から「推薦する」と、`recommendedAt`/`recommendedBy` が付き、**作成した先生へお知らせセンター(#1111・`link.kind='shared-mine'`)で通知**が届く。取り消しは通知なし(audit のみ) +- **推薦印(先生)**: 「自分の投稿」のカードに「推薦」バッジ(限定公開バッジと並ぶ)。詳細には推薦済みの注記 +- **全体公開への発展(先生)**: 自分の限定公開の詳細に「**みんなの課題に公開する**」。共有フォームが**編集モード**(既存メタデータが初期値)で開き、全体公開に必要な属性・著者名・**CC BY 4.0 同意(改めて必須)**を揃えて送信すると `PATCH visibility: 'public'` でカタログに載る +- 推薦通知をクリックすると「自分の投稿」へ直接ジャンプする(クラス未選択ならアクティブな先頭クラスを開いてから表示) + +### 自分の投稿の推薦バッジ + +![自分の投稿と推薦バッジ](screenshots/0105-mine-recommended.png) + +### 全体公開フォーム(編集モード) + +![全体公開フォーム](screenshots/0106-broaden-form.png) + ## 主要ファイル ### バックエンド(infra/smalruby-classroom) diff --git a/docs/assignment-sharing/screenshots/0105-mine-recommended.png b/docs/assignment-sharing/screenshots/0105-mine-recommended.png new file mode 100644 index 00000000000..76d326100c7 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0105-mine-recommended.png differ diff --git a/docs/assignment-sharing/screenshots/0106-broaden-form.png b/docs/assignment-sharing/screenshots/0106-broaden-form.png new file mode 100644 index 00000000000..26bf107e540 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0106-broaden-form.png differ diff --git a/docs/classroom-notification-center/README.md b/docs/classroom-notification-center/README.md new file mode 100644 index 00000000000..70ddf9cd75d --- /dev/null +++ b/docs/classroom-notification-center/README.md @@ -0,0 +1,42 @@ +# クラス管理 お知らせ通知センター & 課題共有パイプライン(トピック集約) + +> **🆕 Smalruby 独自** — topic ブランチ `topic/classroom-notification-center` で実装した 3 つの EPIC を横断的にまとめた紹介ページ。各機能の一次ドキュメントは下記のとおり別にあり、ここは**全体像とスクリーンショットの集約**です。 + +運営(Admin)が良い課題を見つけ → 作成した先生に届け → 先生が「みんなの課題」へ公開する、という一連の流れを支える 3 機能: + +| EPIC | 機能 | 一次ドキュメント | +|------|------|-----------------| +| #1111 | お知らせ通知センター(通知の共通土台) | [`docs/classroom/`](../classroom/ui-ux.md) ・ [`docs/admin/`](../admin/README.md) | +| #1110 | Admin による課題推薦(限定公開 → みんなの課題への発展) | [`docs/assignment-sharing/`](../assignment-sharing/README.md) ・ [`docs/admin/`](../admin/README.md) | +| #1106 | 有益な課題を先生に「みんなの課題へ共有」促す | [`docs/classroom/`](../classroom/ui-ux.md) ・ [`docs/admin/`](../admin/README.md) | + +## パイプライン + +``` +限定公開(合言葉・内輪/先生) + → 運営が把握(限定公開タブ #1110 / 有益候補ダッシュボード #1106) + → 推薦・推奨(お知らせ #1111 が先生に届く) + → 全体公開(CC BY 4.0 同意して先生本人が公開) +``` + +- 公開は著作権(CC BY 4.0)の都合で**必ず先生本人**が行う(運営は代理公開しない) +- 2 つの「気づき」経路(#1110 の推薦 / #1106 の共有推奨)は、どちらも #1111 のお知らせ🔔 に集約され、クリックで該当画面へジャンプする + +## スクリーンショット + +| ファイル | 内容 | +|---------|------| +| `screenshots/0101-notification-bell.png` | #1111 タイトルバーの 🔔 + 未読バッジ | +| `screenshots/0102-notification-panel.png` | #1111 お知らせ一覧パネル | +| `screenshots/0103-admin-notify-form.png` | #1111 管理 SPA の先生へのお知らせ送信フォーム | +| `screenshots/0201-mine-recommended-badges.png` | #1110 先生「自分の投稿」の 限定公開 / 推薦 バッジ | +| `screenshots/0202-broaden-form.png` | #1110 全体公開フォーム(編集モード) | +| `screenshots/0203-admin-shared-recommend.png` | #1110 管理 SPA 限定公開タブ + 推薦の確認 | +| `screenshots/0301-share-suggestion-banner.png` | #1106 課題詳細の共有促しバナー | +| `screenshots/0302-board-share-suggested.png` | #1106 課題一覧(ボード)の「共有おすすめ」マーク | +| `screenshots/0303-admin-recommend-sharing.png` | #1106 管理 SPA クラス詳細の共有推奨の確認 | +| `screenshots/0304-admin-overview-candidates.png` | #1106 俯瞰ダッシュボードの有益候補 | + +## 機能紹介プレゼン(HTML) + +人間が機能を把握するための HTML スライドは **git 管理外の `notes/topic-classroom-notification-center/index.html`** にあります(上記スクリーンショットを相対パスで参照。ブラウザで直接開けます)。 diff --git a/docs/classroom-notification-center/screenshots/0101-notification-bell.png b/docs/classroom-notification-center/screenshots/0101-notification-bell.png new file mode 100644 index 00000000000..19ab7a81a93 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0101-notification-bell.png differ diff --git a/docs/classroom-notification-center/screenshots/0102-notification-panel.png b/docs/classroom-notification-center/screenshots/0102-notification-panel.png new file mode 100644 index 00000000000..6f7cb06f288 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0102-notification-panel.png differ diff --git a/docs/classroom-notification-center/screenshots/0103-admin-notify-form.png b/docs/classroom-notification-center/screenshots/0103-admin-notify-form.png new file mode 100644 index 00000000000..607456d0da8 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0103-admin-notify-form.png differ diff --git a/docs/classroom-notification-center/screenshots/0201-mine-recommended-badges.png b/docs/classroom-notification-center/screenshots/0201-mine-recommended-badges.png new file mode 100644 index 00000000000..56b011d062d Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0201-mine-recommended-badges.png differ diff --git a/docs/classroom-notification-center/screenshots/0202-broaden-form.png b/docs/classroom-notification-center/screenshots/0202-broaden-form.png new file mode 100644 index 00000000000..40037aa3f87 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0202-broaden-form.png differ diff --git a/docs/classroom-notification-center/screenshots/0203-admin-shared-recommend.png b/docs/classroom-notification-center/screenshots/0203-admin-shared-recommend.png new file mode 100644 index 00000000000..ae8dcf1c3a9 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0203-admin-shared-recommend.png differ diff --git a/docs/classroom-notification-center/screenshots/0301-share-suggestion-banner.png b/docs/classroom-notification-center/screenshots/0301-share-suggestion-banner.png new file mode 100644 index 00000000000..e0e0a6d2d76 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0301-share-suggestion-banner.png differ diff --git a/docs/classroom-notification-center/screenshots/0302-board-share-suggested.png b/docs/classroom-notification-center/screenshots/0302-board-share-suggested.png new file mode 100644 index 00000000000..983c69088ca Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0302-board-share-suggested.png differ diff --git a/docs/classroom-notification-center/screenshots/0303-admin-recommend-sharing.png b/docs/classroom-notification-center/screenshots/0303-admin-recommend-sharing.png new file mode 100644 index 00000000000..cc759c43908 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0303-admin-recommend-sharing.png differ diff --git a/docs/classroom-notification-center/screenshots/0304-admin-overview-candidates.png b/docs/classroom-notification-center/screenshots/0304-admin-overview-candidates.png new file mode 100644 index 00000000000..48495b462c3 Binary files /dev/null and b/docs/classroom-notification-center/screenshots/0304-admin-overview-candidates.png differ diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index 7e0bfe417e9..03d3ee09c27 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -130,6 +130,7 @@ sequenceDiagram | **DynamoDB** | `ClassroomGroups-{stage}` | クラス(学級)情報(Streams: OLD_IMAGE) | | **DynamoDB** | `SharedAssignments-{stage}` | みんなの課題(TTL なし・prod RETAIN + PITR) | | **DynamoDB** | `SharedAssignmentReports-{stage}` | みんなの課題の通報(TTL 90日) | +| **DynamoDB** | `ClassroomNotifications-{stage}` | お知らせ(EPIC #1111。TTL は書き手の admin スタックが付与・既定 180 日) | | **S3** | `smalruby-shared-assignments-{stage}` | 共有課題のスナップショット(lifecycle なし = 永続・prod RETAIN) | | **S3** | `smalruby-classroom-submissions-{stage}` | 提出ファイル (.sb3, サムネイル, スクリーンショット) + `ddb-archive/` スナップショット。lifecycle = `ARCHIVE_RETENTION_DAYS`(既定 365 日) | | **Route53** | A レコード | カスタムドメイン | @@ -187,6 +188,8 @@ sequenceDiagram | `PATCH` | `/classroom-groups/{groupId}/topics` | トピックの add / remove / rename(rename・remove は課題へ一括追従) | | `POST` | `/classrooms/{id}/duplicate` | クラス(授業)の複製。課題コンテンツの S3 オブジェクトもコピー。`groupId` / `className` / `assignmentName` を上書き可。メンバー・提出は複製しない | | `POST` | `/classrooms/{id}/evaluate` | AI 評価支援。静的解析結果(シグナル + 擬似コード)を Anthropic API にリレーし、`mode: grade` は S/A/B/C 案 + 根拠 + needsReview、`mode: comment` は生徒向けポジティブコメント下書きを返す。1リクエスト最大10提出(API GW の30秒制限対策、クライアントがチャンク分割)。先生ごとにレート制限(既定 60回/時) | +| `GET` | `/notifications` | お知らせ一覧(EPIC #1111)。自分宛て(teacherSub)の直近 50 件 + `unreadCount` を返す。書き込みは admin スタックのみ(単一ライター) | +| `POST` | `/notifications/mark-read` | お知らせ既読化。`{notificationIds?}` — 省略時は一覧窓(直近 50 件)の未読を全既読化(パネルを開いたらバッジが消える UX) | ### みんなの課題 (共有課題ライブラリ・先生 ID Token 認証) @@ -205,6 +208,7 @@ sequenceDiagram | `POST` | `/shared-assignments/{id}/report` | 通報(理由必須・20件/日制限。reporterSub は内部保持のみ) | - **公開範囲(#1109)**: 項目は `visibility`(`public`/`limited`)を持つ。#1109 以前の項目は属性を持たず `public` とみなす(後方互換)。`limited` は `passcode`(合言葉)を持ち、公開カタログには出ない。「限定公開(合言葉・内輪)→ Admin が把握 → 推薦 → 全体公開」パイプラインの土台 +- **Admin 推薦(#1110)**: 項目は `recommendedAt` / `recommendedBy`(admin email)を持ちうる。書き込みは admin API(`POST/DELETE /admin/shared-assignments/{id}/recommend`)のみ。先生側 API には boolean の `recommended` だけを投影(`recommendedBy` は内部情報)。推薦時は著者へお知らせ(#1111・type `shared_recommended`・`link.kind='shared-mine'`)が飛ぶ - データ: `SharedAssignments{suffix}`(**TTL なし・prod は RETAIN + PITR**。GSI: `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(合言葉ルックアップ・#1109))、`SharedAssignmentReports{suffix}`(TTL 90日) - ファイル: 専用バケット `smalruby-shared-assignments{suffix}`(**lifecycle なし = 永続**、`shared/{sharedId}/` プレフィックス)。クラス側の保存期限と完全に分離 - 共有/取り込みの実体は既存 duplicate と同じ S3 サーバー側コピー(クロスバケット) @@ -244,6 +248,8 @@ erDiagram string googleClassroomCourseId "任意" list coTeacherEmails "共同管理者の email 配列 (任意, 最大10)" map assignment "課題コンテンツ (任意): {pages: [{text, imageKey?}], starterKey?, updatedAt}" + string recommendedForSharingAt "共有推奨 (#1106, 任意)。書き込みは admin API のみ。先生側 API には boolean recommendedForSharing を投影" + string recommendedForSharingBy "推奨した admin の email (内部用・先生側 API では返さない)" string groupId "所属する組 (任意)" string status "active / archived" string createdAt "ISO8601" @@ -313,6 +319,31 @@ erDiagram - `listMembers` / `lookup takenSeats` は `FilterExpression: attribute_not_exists(kicked) OR kicked <> :true` で tombstone を除外 - `joinClassroom` の `ConditionExpression: attribute_not_exists(memberId) OR kicked = :true` で新生徒が kicked 行を上書き可能 (tombstone はその時点で消滅) +### ClassroomNotifications テーブル(お知らせ・EPIC #1111) + +```mermaid +erDiagram + ClassroomNotifications { + string teacherSub PK "宛先の先生 (Google/Microsoft sub)" + string notificationId SK "createdAt ISO + '#' + UUID (時系列ソート)" + string type "admin_message など (リンク種別の意味付け)" + string title "最大100文字" + string body "最大1000文字" + map link "任意: {kind: 'classroom', classroomId} — クリック時のジャンプ先" + string readAt "既読日時 (未読は属性なし)" + string createdBy "送信した admin の email (内部用・API では返さない)" + string createdAt "ISO8601" + number ttl "Unix timestamp (既定180日)" + } +``` + +**GSI なし** — 宛先 (PK) への Query だけで受信箱になる。 + +- **単一ライター**: 書き込みは admin スタック(`POST /admin/notifications`、名前規約 import + write-only grant)のみ。classroom API は一覧と既読化だけを提供するので、エディタ側からお知らせを偽造できない +- 宛先の `teacherSub` は admin SPA に出さず、admin API が classroomId から解決する +- `link.kind` は whitelist(現在 `classroom` のみ)。エディタは未知の kind を無視する(前方互換) +- GUI は先生モーダルのタイトルバー 🔔 から一覧を開く。パネルを開いた時点で mark-read(全件)が走りバッジが消える。60 秒間隔でポーリング + ### ClassroomSubmissions テーブル ```mermaid diff --git a/docs/classroom/screenshots/0218-teacher-notifications.png b/docs/classroom/screenshots/0218-teacher-notifications.png new file mode 100644 index 00000000000..78bed2a5825 Binary files /dev/null and b/docs/classroom/screenshots/0218-teacher-notifications.png differ diff --git a/docs/classroom/screenshots/0219-share-suggestion-banner.png b/docs/classroom/screenshots/0219-share-suggestion-banner.png new file mode 100644 index 00000000000..04bbf6d3d43 Binary files /dev/null and b/docs/classroom/screenshots/0219-share-suggestion-banner.png differ diff --git a/docs/classroom/source-code.md b/docs/classroom/source-code.md index 4f876da164b..4d532db729d 100644 --- a/docs/classroom/source-code.md +++ b/docs/classroom/source-code.md @@ -40,6 +40,7 @@ smalruby3-editor/ │ │ ├── containers/ │ │ │ ├── classroom-modal.jsx ← コンテナ(生徒/先生モードの振り分け) │ │ │ ├── use-teacher-classroom.js ← 先生用フック統合(他フックを束ねる) +│ │ │ ├── use-teacher-notifications.js ← お知らせセンター (EPIC #1111) │ │ │ ├── use-teacher-auth.js ← 認証フック(Google/Microsoft 共通) │ │ │ ├── use-teacher-classrooms.js ← クラス CRUD + 自動リフレッシュ │ │ │ ├── use-teacher-submissions.js ← 提出管理・一括ダウンロード @@ -109,6 +110,8 @@ CDK スタック定義。以下のリソースを作成: | `handleUpdateSubmission()` | 提出更新 (返却・コメント) | | `handleSetAssignment()` | 課題コンテンツ設定 (ページ + スターター、Presigned URL 生成) | | `handleGetAssignment()` | 課題コンテンツ取得 (生徒 Session Token / 先生 ID Token 両対応) | +| `handleListNotifications()` | お知らせ一覧 + 未読数 (EPIC #1111) | +| `handleMarkNotificationsRead()` | お知らせ既読化 (ids 省略で全既読) | | `handleCreateGroup()` / `handleListGroups()` / `handleUpdateGroup()` | 組(グループ)の作成・一覧・更新/アーカイブ | | `handleDuplicateClassroom()` | クラス複製(課題の S3 オブジェクトコピー含む) | | `handleListGoogleCourses()` | Google Classroom コース一覧 | @@ -180,6 +183,7 @@ GOOGLE_ID_TOKEN=eyJ... docker compose run --rm -w /app/infra/smalruby-classroom | `teacher-login-phase.jsx` | ログイン画面(Google / Microsoft ボタン + カルーセル) | | `teacher-sidebar.jsx` | サイドバー(クラス一覧ナビゲーション) | | `teacher-google-courses-phase.jsx` | Google Classroom コースインポート画面 | +| `teacher-notifications.jsx` | お知らせセンター(🔔 + 未読バッジ + 一覧パネル、EPIC #1111) | ### `packages/scratch-gui/src/lib/classroom-api.js` diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index ebb126a5d2a..df356ae7d3d 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -36,7 +36,8 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `classroom-back` | button | 戻る | | `classroom-refresh` | button | 更新 (↻) | | `classroom-google-import` | button | Google Classroom からインポート | -| `classroom-teacher-logout` | button | ログアウト | +| `classroom-avatar-button` | button | アカウントメニュー(タイトルバー右上・メール頭文字 + ▼) | +| `classroom-teacher-logout` | button | ログアウト(アカウントメニュー内) | ### クラス作成 @@ -204,7 +205,8 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `classroom-class-open-{groupId}` | button | クラスカード本体(クリックでクラスをひらく) | | `classroom-class-evaluate-{groupId}` | button | クラスカードの「評価」 | | `classroom-class-import-gc` | button | クラス一覧の「Google Classroom からインポート」(Google ログイン時のみ) | -| `classroom-teacher-logout` | button | ログアウト(タイトルバー右端に常時表示) | +| `classroom-avatar-button` / `classroom-avatar-initials` / `classroom-avatar-email` | button/span | アカウントメニュー(右上・メール頭文字 + ▼。クリックでメール表示 + ログアウト) | +| `classroom-teacher-logout` | button | ログアウト(アカウントメニュー内。#1111 レビューでタイトルバー常時表示から移動) | | `classroom-class-create-section` | input | 同時作成: セクション(オプション) | | `classroom-class-settings-open-{groupId}` | button | クラスカードの「設定」(インライン編集を開く) | | `classroom-class-settings-{groupId}` | form | クラス設定のインライン編集フォーム | @@ -275,7 +277,13 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `shared-report-form` / `shared-report-reason` / `shared-report-submit` | div/textarea/button | 通報理由(必須)と送信 | | `shared-report-sent` | p | 通報完了メッセージ | | `shared-detail-unlist` / `shared-detail-republish` | button | 自分の投稿の取り下げ / 再公開 | +| `shared-card-limited-badge` / `shared-card-recommended-badge` | span | カードの 限定公開 / 推薦 バッジ(#1110) | +| `shared-detail-recommended-note` | p | 自分の限定公開が推薦を受けたときの注記(#1110) | +| `shared-detail-broaden` | button | 「みんなの課題に公開する」(自分の限定公開のみ・#1110) | +| `shared-broaden-done` | p | 全体公開の完了メッセージ(#1110) | | `shared-import-success` | p | 取り込み完了メッセージ(ボード上) | +| `classroom-share-suggestion-banner` / `classroom-share-suggestion-open` | div/button | 共有推奨バナーと「共有フォームを開く」CTA(#1106) | +| `classroom-board-share-suggested-{classroomId}` | span | ボード行の「共有おすすめ」マーク(#1106) | | `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) | | `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク | | `classroom-board-create-name` / `classroom-board-create-submit` | input / button | インライン課題作成(課題名のみ) | @@ -288,7 +296,7 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `classroom-description-preview` | div | 右ペインの生徒視点プレビュー | | `classroom-description-preview-body` | div | プレビュー本文(テキスト+画像) | | `classroom-description-preview-prev` / `-next` | button | プレビューのページ送り | -| `classroom-teacher-email` | span | タイトルバーのユーザーメール(OIDC トークンに email がある場合のみ) | +| `classroom-avatar-email` | span | アカウントメニュー内のユーザーメール(OIDC トークンに email がある場合のみ。旧 `classroom-teacher-email` は廃止) | | `classroom-board-create-cancel` / `classroom-board-reuse-cancel` | button | インラインフォームのキャンセル | | `classroom-google-course-imported-{courseId}` | span | GC コースの「インポート済み」バッジ | | `classroom-breadcrumb-assignments` | button | パンくず「課題一覧」リンク | diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index 94b5f2867a7..4c45ab63b9c 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -165,6 +165,58 @@ Google または Microsoft アカウントでサインインする画面。先 - 課題の所属クラス変更・人数編集・課題単位の共同管理者・複製は**できない**(クラス設定 / 課題一覧の再利用へ集約)。フッターのボタンは「**課題をアーカイブ**」(soft-delete。ボードの「アーカイブ済みの課題」からいつでも復元可能。testid は歴史的経緯で `classroom-delete-classroom` のまま) - 主な data-testid: `classroom-tab-description` / `classroom-description-editor` / `classroom-description-preview[-body|-prev|-next]` / `classroom-tab-members` +## 1.8 先生: お知らせセンター(タイトルバー・EPIC #1111) + +![お知らせセンター](screenshots/0218-teacher-notifications.png) + +クラス管理のタイトルバー右上(× の左隣)は **アバターメニュー**(メール頭文字 + ▼)に固定。その左に **白一色のベル**(お知らせ)を置く。運営(Admin)からのお知らせが届くと未読数バッジが付く。 + +- **アバターメニュー**: メール頭文字(`kouji@…`→`K` / `kouji.takao@…`→`KT`)の丸アイコン + ▼。クリックでポップアップ(メール表示 + ログアウト。将来の設定項目もここに集約)。紫背景で視認できる白丸 + 紫文字 +- ベルのクリックで一覧パネルを開閉。**開くだけでは既読にしない**(バッジは残る)。既読はパネルヘッダーの **⋯ メニュー**から明示的に行う +- パネルは **先頭 5 件**のみプレビュー表示。ヘッダーの **⋯ メニュー**に「**すべて既読にする**」「**お知らせを開く**(全件一覧ページ・`teacher-notifications` フェーズ・**10 件/ページ**)」。一覧は件数に関係なく開ける +- お知らせ本文をクリックすると、`link` の種類に応じて該当画面へジャンプ(`kind: 'classroom'` → そのクラスを選択して課題詳細へ / `kind: 'shared-mine'` → みんなの課題の自分の投稿へ)。未知の kind は無視(前方互換) +- **全件一覧ページ**は「**クラス管理 > お知らせ**」パンくず(先頭「クラス管理」でトップ=クラス一覧へ戻れる)+ **左下に「戻る」ボタン**(キャンセルが不適切な画面のポリシー) +- **取得は 1 日 1 回**(コスト削減): その日はじめてクラス管理を開いたときだけ `GET /notifications` を 1 回呼び、localStorage に日付つきでキャッシュ。同じ日の再オープンは API を叩かない(旧 60 秒ポーリングは廃止)。共有 PC 対策として先生メールでキャッシュを識別。取得エラーはクラス管理本体に影響させない(表示しない)。※その日の初回取得後に届いたお知らせは翌日反映(運営連絡は多くて週 1 回程度の前提) +- 送信側は Admin SPA(クラス詳細の「先生へのお知らせ」フォーム → `POST /admin/notifications`)。`docs/admin/README.md` を参照 + +| 要素 | data-testid | 操作 | +|------|-------------|------| +| アバターボタン | `classroom-avatar-button` | クリックでアカウントメニュー開閉 | +| イニシャル丸 | `classroom-avatar-initials` | メール頭文字(1〜2 文字) | +| メニューのメール | `classroom-avatar-email` | ポップアップ内のメール表示 | +| ログアウト | `classroom-teacher-logout` | アカウントメニュー内 | +| ベルボタン | `classroom-notifications-button` | クリックでパネル開閉 | +| 未読バッジ | `classroom-notifications-badge` | 未読数(10 以上は「9+」)。未読 0 で非表示 | +| 一覧パネル | `classroom-notifications-panel` | 先頭 5 件プレビュー | +| ⋯ メニューボタン | `classroom-notifications-menu-button` | パネルヘッダー右の三点 | +| ⋯ メニュー | `classroom-notifications-menu` | — | +| すべて既読にする | `classroom-notifications-mark-all-read` | 全件既読(未読 0 で無効) | +| お知らせを開く | `classroom-notifications-open-all` | 全件一覧ページへ(件数不問) | +| お知らせ 1 件(パネル) | `classroom-notification-item-{notificationId}` | クリックでリンク先へジャンプ + パネルを閉じる | +| 未読ドット | `classroom-notification-unread-dot` | 未読アイテムのみ | +| 空メッセージ | `classroom-notifications-empty` | お知らせ 0 件のとき | +| 全件一覧ページ | `classroom-notifications-page` | `teacher-notifications` フェーズ | +| パンくず先頭 | `classroom-breadcrumb-top` | 「クラス管理」→ トップ(クラス一覧)へ | +| 左下戻る | `classroom-notifications-back` | トップ(クラス一覧)へ戻る | +| ページ内 1 件 | `classroom-notification-page-item-{notificationId}` | クリックでリンク先へジャンプ | +| ページャ | `classroom-notifications-pager` / `-prev` / `-next` | 10 件/ページ | + +## 1.9 先生: 共有推奨バナー(#1106) + +![共有推奨バナー](screenshots/0219-share-suggestion-banner.png) + +運営(Admin)が「みんなの課題に共有する価値がある」と判断した課題には、課題詳細の上部に「**この課題、みんなの課題に共有しませんか?**」バナーが出る。「共有フォームを開く」でボードの共有ステップ(既存の共有フロー)が開く。公開はあくまで CC BY 同意を伴う**先生本人の共有操作のみ**(運営による代理公開はしない)。 + +- 推奨と同時に運営からのお知らせ(🔔・`share_suggestion`)も届き、クリックでこの課題の詳細へジャンプする +- 課題一覧(ボード)の該当行には「**共有おすすめ**」マークが付く +- フラグは admin が取り消すまで表示される(先生側から消す操作は無い) + +| 要素 | data-testid | 操作 | +|------|-------------|------| +| バナー | `classroom-share-suggestion-banner` | — | +| 共有フォームを開く | `classroom-share-suggestion-open` | ボードへ戻って共有ステップを開く | +| ボード行のマーク | `classroom-board-share-suggested-{classroomId}` | — | + ## 4. 先生: クラス詳細 (`teacher-detail`) クラスの参加状況と提出を管理する画面。モーダルが**ワイド表示 (968px)** に広がります。 diff --git a/infra/smalruby-admin/lambda/classroom-overview.ts b/infra/smalruby-admin/lambda/classroom-overview.ts index a3b7894aaca..b99a0e0c34a 100644 --- a/infra/smalruby-admin/lambda/classroom-overview.ts +++ b/infra/smalruby-admin/lambda/classroom-overview.ts @@ -15,7 +15,12 @@ export interface ClassroomRow { teacherSub?: unknown; status?: unknown; // 'active' | 'archived' createdAt?: unknown; - content?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string }; + // 課題コンテンツは classroom item の `assignment` 属性(`content` は + // SharedAssignments 側のフィールド名 — 取り違えると richness が常に 0 で + // 有益候補が空になるバグだった #1106)。 + assignment?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string }; + // 共有推奨 (#1106): admin が立てるフラグ。候補一覧に「推奨済み」を出す。 + recommendedForSharingAt?: unknown; } /** Quota rows reuse the classrooms key space; never count them. */ @@ -33,10 +38,10 @@ export function isRealClassroom(row: ClassroomRow): boolean { export function richness(row: ClassroomRow): { score: number; pageCount: number; hasImages: boolean; hasStarter: boolean; } { - const pages = row.content?.pages || []; + const pages = row.assignment?.pages || []; const pageCount = pages.length; const hasImages = pages.some(p => typeof p?.imageKey === 'string' && p.imageKey.length > 0); - const hasStarter = typeof row.content?.starterKey === 'string' && row.content.starterKey.length > 0; + const hasStarter = typeof row.assignment?.starterKey === 'string' && row.assignment.starterKey.length > 0; let score = 0; if (pageCount >= 1) score += 1; if (pageCount >= 2) score += 1; @@ -78,6 +83,7 @@ export function buildOverview( classroomId: string; className: string; assignmentName: string; teacherSub: string; score: number; pageCount: number; hasImages: boolean; hasStarter: boolean; createdAt: string; likelyShared: boolean; + recommendedForSharing: boolean; }[]; themeKeywords: { keyword: string; count: number }[]; } { @@ -127,6 +133,7 @@ export function buildOverview( hasStarter: r.hasStarter, createdAt, likelyShared, + recommendedForSharing: !!row.recommendedForSharingAt, }); } } diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index ff70063aa4c..bdd4503532d 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -27,6 +27,7 @@ import { import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { OAuth2Client } from 'google-auth-library'; +import { randomUUID } from 'crypto'; import { buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot, } from './restore-plan'; @@ -50,6 +51,13 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups'; const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions'; const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10); +// お知らせ (notification center, EPIC #1111): this stack is the single +// writer; teachers read through the classroom API. Notices are ephemeral +// guidance, so the writer stamps a TTL (default 180 days). +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; +const NOTIFICATION_TTL_DAYS = parseInt(process.env.NOTIFICATION_TTL_DAYS || '180', 10); +const MAX_NOTIFICATION_TITLE_LENGTH = 100; +const MAX_NOTIFICATION_MESSAGE_LENGTH = 1000; const dynamoClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(dynamoClient); @@ -218,6 +226,12 @@ function mapSharedItemForAdmin(item: Record) { reuseCount: (item.reuseCount as number) || 0, pageCount: (content.pages || []).length, hasStarter: !!content.starterKey, + // 公開範囲 (#1109) と Admin 推薦 (#1110)。passcode 自体は運営にも不要 + // なので出さない(最小露出)。 + visibility: (item.visibility as string) || 'public', + recommended: !!item.recommendedAt, + recommendedAt: item.recommendedAt || null, + recommendedBy: item.recommendedBy || null, createdAt: item.createdAt, updatedAt: item.updatedAt, }; @@ -291,11 +305,17 @@ async function handleListSharedReports(identity: AdminIdentity): Promise, ): Promise { - audit('shared.list', identity, { q: query.q || null, status: query.status || null }); + audit('shared.list', identity, { + q: query.q || null, status: query.status || null, visibility: query.visibility || null, + }); let items = (await scanAll(SHARED_ASSIGNMENTS_TABLE)).map(mapSharedItemForAdmin); if (query.status) { items = items.filter(item => item.status === query.status); } + // 限定公開の把握 (#1110): 推薦候補の母集団を絞る。 + if (query.visibility) { + items = items.filter(item => item.visibility === query.visibility); + } if (query.q) { const q = query.q.toLowerCase(); items = items.filter(item => @@ -374,6 +394,95 @@ async function handleSetSharedStatus( }; } +// --- Admin 推薦 (EPIC #1110): 限定公開 → みんなの課題への発展 --- + +/** + * POST/DELETE /admin/shared-assignments/{sharedId}/recommend — mark a shared + * assignment as operator-recommended (or withdraw the mark). Recommending + * notifies the author through the notification center (#1111) so the teacher + * can broaden a 限定公開 item to the public catalog. Idempotent: recommending + * an already-recommended item neither rewrites the mark nor re-notifies. + */ +async function handleSetSharedRecommendation( + identity: AdminIdentity, sharedId: string, recommended: boolean, +): Promise { + const result = await docClient.send(new GetCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + })); + const item = result.Item as Record | undefined; + if (!item || item.status !== 'published') { + // Unlisted items are not recommendable — the author withdrew them. + throw new NotFoundError('Shared assignment not found'); + } + + const alreadyRecommended = !!item.recommendedAt; + if (recommended && !alreadyRecommended) { + // 推薦は「限定公開 → 全体公開への発展」の働きかけ (#1110)。公開済みの + // 項目に送っても通知文(全体公開の検討を促す)が意味を成さないため + // 限定公開に限定する(レビュー指摘)。取り消し (下) は無条件に許す。 + if (((item.visibility as string) || 'public') !== 'limited') { + throw new ValidationError('Only limited-visibility items can be recommended'); + } + const now = new Date().toISOString(); + // 通知が主目的なので先に通知 → 印付けの順にする。印付けが失敗しても + // リトライで再通知 + 印付けが成立する(逆順だと印だけ付いて通知が + // 永久に失われ、no-op ガードで再送もできない — レビュー指摘)。最悪 + // ケースは通知の重複で、先生側では既読で流せる無害な事象。 + const authorSub = typeof item.authorSub === 'string' ? item.authorSub : ''; + if (authorSub) { + await putNotification(authorSub, { + type: 'shared_recommended', + title: 'あなたの課題が推薦されました', + body: `「${String(item.title)}」が運営の推薦を受けました。みんなの課題への全体公開を検討してみませんか?`, + link: { kind: 'shared-mine', sharedId }, + createdBy: identity.email, + }); + } + try { + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'SET recommendedAt = :now, recommendedBy = :email, updatedAt = :now', + // 2 人の運営が同時に推薦しても印付けと通知が二重にならないよう、 + // 冪等判定を原子的にする(レビュー指摘)。 + ConditionExpression: 'attribute_not_exists(recommendedAt)', + ExpressionAttributeValues: { ':now': now, ':email': identity.email }, + })); + } catch (err) { + // 競合で先に推薦されていた場合は already-recommended と同じ扱い。 + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + audit('shared.recommend', identity, { sharedId }); + return { + statusCode: 200, + body: JSON.stringify(mapSharedItemForAdmin({ + ...item, recommendedAt: now, recommendedBy: identity.email, + })), + }; + } + + if (!recommended && alreadyRecommended) { + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'REMOVE recommendedAt, recommendedBy SET updatedAt = :now', + ExpressionAttributeValues: { ':now': new Date().toISOString() }, + })); + // 取り消しは通知しない(先生を騒がせない)。audit で追跡できる。 + audit('shared.unrecommend', identity, { sharedId }); + return { + statusCode: 200, + body: JSON.stringify(mapSharedItemForAdmin({ + ...item, recommendedAt: undefined, recommendedBy: undefined, + })), + }; + } + + // No-op (already in the requested state) — idempotent success. + return { statusCode: 200, body: JSON.stringify(mapSharedItemForAdmin(item)) }; +} + // --- Classroom management + expired restore (S4 #1084) --- // The restore UI supersedes classroom's ops CLI (EPIC #1049 D6 update): // snapshots written by the classroom archiver under @@ -395,9 +504,110 @@ function mapClassroomForAdmin(item: Record) { updatedAt: item.updatedAt || null, restoredAt: item.restoredAt || null, expiresAt: item.ttl ? new Date((item.ttl as number) * 1000).toISOString() : null, + // 共有推奨 (#1106) + recommendedForSharing: !!item.recommendedForSharingAt, + recommendedForSharingAt: item.recommendedForSharingAt || null, + recommendedForSharingBy: item.recommendedForSharingBy || null, }; } +// --- 共有推奨 (EPIC #1106): 有益な課題を先生に「みんなの課題へ共有」促す --- + +/** + * POST/DELETE /admin/classrooms/{classroomId}/recommend-sharing — flag an + * assignment as "worth sharing to みんなの課題" (or withdraw the flag). + * Flagging notifies the owning teacher through the notification center + * (#1111); the teacher's own share flow (CC BY consent) stays the only + * publication path — admins never publish on a teacher's behalf. + */ +async function handleSetSharingRecommendation( + identity: AdminIdentity, classroomId: string, recommended: boolean, +): Promise { + const result = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + const item = result.Item as Record | undefined; + // Quota rows share the key space. + if (!item || String(classroomId).includes('-quota#')) { + throw new NotFoundError('Classroom not found'); + } + const teacherSub = typeof item.teacherSub === 'string' ? item.teacherSub : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + // アーカイブ済みは先生から見えないので新規の推奨はしない。ただし取り消し + // (下) は許す — 推奨後にアーカイブされるとフラグが運営から触れなくなり、 + // 復元時に古いバナーが再出現するため(レビュー指摘)。 + if (recommended && item.status !== 'active') { + throw new NotFoundError('Classroom not found'); + } + // 中身(説明ページ or スターター)が無い課題は共有 API が拒否するので、 + // 「共有しませんか?」と促しても行き止まりになる — 推奨自体を拒否する + // (レビュー指摘)。 + const assignment = item.assignment as { pages?: unknown[]; starterKey?: string } | undefined; + const hasAssignmentContent = !!assignment && + ((Array.isArray(assignment.pages) && assignment.pages.length > 0) || !!assignment.starterKey); + if (recommended && !hasAssignmentContent) { + throw new ValidationError('Classroom has no assignment content to share'); + } + + const alreadyRecommended = !!item.recommendedForSharingAt; + if (recommended && !alreadyRecommended) { + const now = new Date().toISOString(); + // #1110 と同じ「通知 → 印付け」の順 + 印付けの冪等を原子化。真に同時の + // POST では通知が重複しうる(SPA の busy 無効化で実質防止・最悪ケースは + // 重複通知で無害 — #1110 と同じ割り切り)。 + const title = String(item.assignmentName || item.className || '課題'); + await putNotification(teacherSub, { + type: 'share_suggestion', + title: 'この課題、みんなの課題に共有しませんか?', + body: `「${title}」が内容の充実した課題として運営のおすすめに選ばれました。課題一覧の「共有」から、全国の先生に共有できます。`, + link: { kind: 'classroom', classroomId }, + createdBy: identity.email, + }); + try { + await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + UpdateExpression: + 'SET recommendedForSharingAt = :now, recommendedForSharingBy = :email, updatedAt = :now', + ConditionExpression: 'attribute_not_exists(recommendedForSharingAt)', + ExpressionAttributeValues: { ':now': now, ':email': identity.email }, + })); + } catch (err) { + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + audit('classroom.recommendSharing', identity, { classroomId }); + return { + statusCode: 200, + body: JSON.stringify(mapClassroomForAdmin({ + ...item, recommendedForSharingAt: now, recommendedForSharingBy: identity.email, + })), + }; + } + + if (!recommended && alreadyRecommended) { + await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + UpdateExpression: 'REMOVE recommendedForSharingAt, recommendedForSharingBy SET updatedAt = :now', + ExpressionAttributeValues: { ':now': new Date().toISOString() }, + })); + // 取り消しは通知しない(先生を騒がせない)。audit で追跡できる。 + audit('classroom.unrecommendSharing', identity, { classroomId }); + return { + statusCode: 200, + body: JSON.stringify(mapClassroomForAdmin({ + ...item, recommendedForSharingAt: undefined, recommendedForSharingBy: undefined, + })), + }; + } + + // No-op (already in the requested state) — idempotent success. + return { statusCode: 200, body: JSON.stringify(mapClassroomForAdmin(item)) }; +} + async function handleListClassrooms( identity: AdminIdentity, query: Record, ): Promise { @@ -495,6 +705,103 @@ async function handleSetClassroomStatus( }; } +// --- お知らせ (notification center, EPIC #1111) --- + +/** + * Notification link targets the teacher UI knows how to open. Kept as a + * whitelist so a typo'd kind can never be stored (the editor ignores + * unknown kinds, but the audit trail should stay clean). + * - 'classroom': open the referenced assignment in its class board context + * - 'shared-mine': open みんなの課題 の「自分の投稿」 (#1110 recommendation) + */ +const NOTIFICATION_LINK_KINDS = new Set(['classroom', 'shared-mine']); + +/** + * Write one notice into the teacher's inbox (single-writer: only this stack + * ever creates rows; the classroom API lists/marks-read them). + * @param teacherSub - recipient (resolved internally, never sent by the SPA) + * @param fields - type/title/body/link + the acting admin's email + * @returns the created notificationId + */ +async function putNotification( + teacherSub: string, + fields: { + type: string; + title: string; + body: string; + link: Record | null; + createdBy: string; + }, +): Promise { + // Validate here (not at each call site) so every future send path — e.g. + // the recommendation notices of #1110/#1106 — goes through the whitelist. + if (fields.link && !NOTIFICATION_LINK_KINDS.has(String(fields.link.kind))) { + throw new ValidationError('Unsupported link kind'); + } + const createdAt = new Date().toISOString(); + // createdAt-prefixed sort key → the inbox Query returns newest first. + const notificationId = `${createdAt}#${randomUUID()}`; + await docClient.send(new PutCommand({ + TableName: NOTIFICATIONS_TABLE, + Item: { + teacherSub, + notificationId, + type: fields.type, + title: fields.title, + body: fields.body, + ...(fields.link ? { link: fields.link } : {}), + createdBy: fields.createdBy, + createdAt, + ttl: Math.floor(Date.now() / 1000) + NOTIFICATION_TTL_DAYS * 24 * 60 * 60, + }, + })); + return notificationId; +} + +/** + * POST /admin/notifications — send a notice to the teacher who owns the + * given classroom. The SPA sends a classroomId, never a teacherSub: subs + * stay internal (same principle as authorSub in the shared projections). + */ +async function handleSendNotification( + identity: AdminIdentity, body: Record, +): Promise { + const classroomId = typeof body.classroomId === 'string' ? body.classroomId.trim() : ''; + if (!classroomId) { + throw new ValidationError('classroomId is required'); + } + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title || title.length > MAX_NOTIFICATION_TITLE_LENGTH) { + throw new ValidationError(`title is required (at most ${MAX_NOTIFICATION_TITLE_LENGTH} characters)`); + } + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (!message || message.length > MAX_NOTIFICATION_MESSAGE_LENGTH) { + throw new ValidationError(`message is required (at most ${MAX_NOTIFICATION_MESSAGE_LENGTH} characters)`); + } + + const result = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + const teacherSub = result.Item && typeof result.Item.teacherSub === 'string' + ? result.Item.teacherSub + : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + + const notificationId = await putNotification(teacherSub, { + type: 'admin_message', + title, + body: message, + link: { kind: 'classroom', classroomId }, + createdBy: identity.email, + }); + audit('notification.send', identity, { classroomId, type: 'admin_message' }); + + return { statusCode: 201, body: JSON.stringify({ notificationId }) }; +} + // --- ddb-archive snapshot helpers --- async function readSnapshot(key: string): Promise { @@ -741,6 +1048,14 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { expect(o.candidates.some(c => c.classroomId === 'c3')).toBe(false); }); + test('候補に共有推奨フラグが載る (#1106)', () => { + expect(o.candidates.find(c => c.classroomId === 'c1')?.recommendedForSharing).toBe(true); + expect(o.candidates.find(c => c.classroomId === 'c2')?.recommendedForSharing).toBe(false); + }); + + test('classroom item の実フィールド名は assignment(content だと richness が 0 になる回帰)', () => { + expect(richness({ content: { pages: [{ text: 'a' }], starterKey: 's' } } as never).score).toBe(0); + }); + test('theme keywords tally repeated tokens only', () => { // '5年1組' appears on c1 and c3 expect(o.themeKeywords.some(k => k.keyword === '5年1組' && k.count === 2)).toBe(true); diff --git a/infra/smalruby-admin/lambda/tests/handler-notifications.test.ts b/infra/smalruby-admin/lambda/tests/handler-notifications.test.ts new file mode 100644 index 00000000000..c206038ff43 --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/handler-notifications.test.ts @@ -0,0 +1,170 @@ +/** + * お知らせ送信 API tests — EPIC #1111. + * + * The admin stack is the single writer of teacher notices. The SPA only + * ever sends a classroomId — the recipient teacherSub is resolved here and + * never crosses the wire (same principle as authorSub in the shared + * projections). + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, token?: string, body?: unknown) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'https://smalruby.app', + }, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +describe('POST /admin/notifications (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + /** + * Route DynamoDB commands: the allowlist Get (SmalrubyAdmins), the + * classroom Get, and the notification Put. + * @param classroom - the Classrooms item returned for the lookup (null = missing) + * @returns collected PutCommand inputs + */ + const wireMocks = (classroom: Record | null) => { + const puts: Array> = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('Classrooms')) { + return { Item: classroom }; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return puts; + }; + + test('writes a notice addressed to the classroom owner, 201', async () => { + const puts = wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', + title: '運営からのお知らせ', + message: 'この課題、みんなの課題に共有しませんか?', + })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body as string).notificationId).toBeTruthy(); + expect(puts).toHaveLength(1); + const item = puts[0].Item as Record; + expect(item.teacherSub).toBe('teacher-sub-9'); + expect(item.type).toBe('admin_message'); + expect(item.title).toBe('運営からのお知らせ'); + expect(item.body).toBe('この課題、みんなの課題に共有しませんか?'); + expect(item.link).toEqual({ kind: 'classroom', classroomId: 'c1' }); + expect(item.createdBy).toBe('dev-admin@example.com'); + // createdAt-prefixed sort key (chronological inbox) + TTL stamped. + expect(String(item.notificationId)).toMatch(/^\d{4}-\d{2}-\d{2}T.*#[0-9a-f-]+$/); + expect(typeof item.ttl).toBe('number'); + }); + + test('404 when the classroom does not exist (nothing written)', async () => { + const puts = wireMocks(null); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'missing', + title: 't', + message: 'm', + })); + expect(res.statusCode).toBe(404); + expect(puts).toHaveLength(0); + }); + + test('400 on missing/oversized fields', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const cases = [ + { title: 't', message: 'm' }, // no classroomId + { classroomId: 'c1', message: 'm' }, // no title + { classroomId: 'c1', title: 't' }, // no message + { classroomId: 'c1', title: 'x'.repeat(101), message: 'm' }, + { classroomId: 'c1', title: 't', message: 'x'.repeat(1001) }, + ]; + for (const body of cases) { + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, body)); + expect(res.statusCode).toBe(400); + } + }); + + test('401 without a token, 403 for a non-admin', async () => { + let res = await handler(makeEvent('POST', '/admin/notifications', undefined, {})); + expect(res.statusCode).toBe(401); + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ + sub: 'stranger', email: 'stranger@example.com', email_verified: true, name: null, + }), + }); + mockSend.mockResolvedValue({}); // no allowlist row + res = await handler(makeEvent('POST', '/admin/notifications', 'stranger-token', { + classroomId: 'c1', title: 't', message: 'm', + })); + expect(res.statusCode).toBe(403); + }); + + test('audit log records the send', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', title: 't', message: 'm', + })); + const auditLines = logSpy.mock.calls + .map(args => String(args[0])) + .filter(line => line.includes('"audit":true')); + expect(auditLines.some(line => line.includes('"action":"notification.send"'))).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts b/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts new file mode 100644 index 00000000000..519ad75d1a6 --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts @@ -0,0 +1,182 @@ +/** + * 共有推奨 API tests — EPIC #1106. + * + * Flagging an assignment as "worth sharing to みんなの課題" notifies the + * owning teacher (#1111) so they can share it themselves (CC BY consent + * stays with the author — admins never publish on a teacher's behalf). + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, classroomId: string) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + pathParameters: { classroomId }, +}); + +const classroomItem = (over: Record = {}) => ({ + classroomId: 'c1', + className: '2年1組', + assignmentName: 'ねこ迷路ゲーム', + teacherSub: 'teacher-sub-9', + joinCode: 'abc234', + studentCount: 30, + assignment: { pages: [{ text: 'ページ1' }], starterKey: 'c1/assignment/starter.sb3' }, + status: 'active', + createdAt: '2026-07-10T00:00:00.000Z', + ...over, +}); + +describe('POST/DELETE /admin/classrooms/{id}/recommend-sharing (EPIC #1106)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + const wireMocks = (classroom: Record | null) => { + const updates: Array> = []; + const puts: Array> = []; + const order: string[] = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + order.push(name); + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('Classrooms')) { + return { Item: classroom }; + } + if (name === 'UpdateCommand') { + updates.push(command.input as Record); + return {}; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return { updates, puts, order }; + }; + + test('flags the assignment and notifies the owning teacher (通知→印付けの順)', async () => { + const { updates, puts, order } = wireMocks(classroomItem()); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + + expect(puts).toHaveLength(1); + const notice = puts[0].Item as Record; + expect(notice.teacherSub).toBe('teacher-sub-9'); + expect(notice.type).toBe('share_suggestion'); + expect(String(notice.title)).toContain('共有しませんか'); + expect(String(notice.body)).toContain('ねこ迷路ゲーム'); + expect(notice.link).toEqual({ kind: 'classroom', classroomId: 'c1' }); + + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('SET recommendedForSharingAt'); + expect(updates[0].ConditionExpression).toContain('attribute_not_exists(recommendedForSharingAt)'); + expect(order.indexOf('PutCommand')).toBeLessThan(order.indexOf('UpdateCommand')); + + const body = JSON.parse(res.body as string); + expect(body.recommendedForSharing).toBe(true); + // teacherSub never crosses to the SPA. + expect(res.body).not.toContain('teacher-sub-9'); + }); + + test('already-flagged is a no-op (no re-notify)', async () => { + const { updates, puts } = wireMocks(classroomItem({ + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal removes the flag silently', async () => { + const { updates, puts } = wireMocks(classroomItem({ + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('REMOVE recommendedForSharingAt, recommendedForSharingBy'); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommendedForSharing).toBe(false); + }); + + test('400 when the assignment has no content (共有の行き止まり防止)', async () => { + const { updates, puts } = wireMocks(classroomItem({ assignment: undefined })); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(400); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal works even after the classroom was archived (復元時の古いバナー防止)', async () => { + const { updates, puts } = wireMocks(classroomItem({ + status: 'archived', + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(puts).toHaveLength(0); + }); + + test('404 for missing / archived / quota-row classrooms', async () => { + for (const [item, id] of [ + [null, 'missing'], + [classroomItem({ status: 'archived' }), 'c1'], + [classroomItem({ classroomId: 'eval-quota#t#2026' }), 'eval-quota#t#2026'], + ] as const) { + wireMocks(item as Record | null); + const res = await handler(makeEvent( + 'POST', `/admin/classrooms/${id}/recommend-sharing`, String(id), + )); + expect(res.statusCode).toBe(404); + } + }); +}); diff --git a/infra/smalruby-admin/lambda/tests/handler-shared-recommendation.test.ts b/infra/smalruby-admin/lambda/tests/handler-shared-recommendation.test.ts new file mode 100644 index 00000000000..ded1c590098 --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/handler-shared-recommendation.test.ts @@ -0,0 +1,221 @@ +/** + * Admin 推薦 API tests — EPIC #1110. + * + * Recommending a shared assignment marks it (recommendedAt/recommendedBy) + * and notifies the author through the notification center (#1111). + * Withdrawal removes the mark silently. Both are idempotent. + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, sharedId: string) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + pathParameters: { sharedId }, +}); + +const limitedItem = (over: Record = {}) => ({ + sharedId: 's1', + title: 'ねこあつめ入門', + authorSub: 'teacher-sub-9', + visibility: 'limited', + passcode: 'abc234', + status: 'published', + reuseCount: 0, + createdAt: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-07-20T00:00:00.000Z', + ...over, +}); + +describe('POST/DELETE /admin/shared-assignments/{id}/recommend (EPIC #1110)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + /** + * Route DynamoDB commands for the recommend flow. + * @param shared - the SharedAssignments item (null = missing) + * @returns collected {updates, puts} command inputs + */ + const wireMocks = (shared: Record | null) => { + const updates: Array> = []; + const puts: Array> = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('SharedAssignments')) { + return { Item: shared }; + } + if (name === 'UpdateCommand') { + updates.push(command.input as Record); + return {}; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return { updates, puts }; + }; + + test('recommend marks the item and notifies the author (#1111 inbox)', async () => { + const { updates, puts } = wireMocks(limitedItem()); + const commandOrder: string[] = []; + const prevImpl = mockSend.getMockImplementation()!; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + commandOrder.push(command.constructor.name); + return prevImpl(command); + }); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('SET recommendedAt'); + // 冪等判定は原子的(同時 POST の二重通知防止)。 + expect(updates[0].ConditionExpression).toContain('attribute_not_exists(recommendedAt)'); + // 通知が主目的なので Put(通知)→ Update(印付け)の順。逆だと印付け後の + // 通知失敗をリトライで回復できない。 + expect(commandOrder.indexOf('PutCommand')).toBeLessThan(commandOrder.indexOf('UpdateCommand')); + + expect(puts).toHaveLength(1); + const notice = puts[0].Item as Record; + expect(notice.teacherSub).toBe('teacher-sub-9'); + expect(notice.type).toBe('shared_recommended'); + expect(String(notice.body)).toContain('ねこあつめ入門'); + expect(notice.link).toEqual({ kind: 'shared-mine', sharedId: 's1' }); + + const body = JSON.parse(res.body as string); + expect(body.recommended).toBe(true); + expect(body.recommendedBy).toBe('dev-admin@example.com'); + // passcode never crosses to the SPA even for operators. + expect(res.body).not.toContain('abc234'); + expect(res.body).not.toContain('authorSub'); + }); + + test('recommending an already-recommended item is a no-op (no re-notify)', async () => { + const { updates, puts } = wireMocks(limitedItem({ + recommendedAt: '2026-07-21T00:00:00.000Z', recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommended).toBe(true); + }); + + test('withdrawal removes the mark without notifying', async () => { + const { updates, puts } = wireMocks(limitedItem({ + recommendedAt: '2026-07-21T00:00:00.000Z', recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('REMOVE recommendedAt, recommendedBy'); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommended).toBe(false); + }); + + test('400 when recommending a public item (発展の働きかけ先ではない)', async () => { + const { updates, puts } = wireMocks(limitedItem({ visibility: 'public', passcode: undefined })); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(400); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal still works after the item became public (推薦は残っている)', async () => { + const { updates } = wireMocks(limitedItem({ + visibility: 'public', + passcode: undefined, + recommendedAt: '2026-07-21T00:00:00.000Z', + recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + }); + + test('404 for missing or unlisted items (author withdrew it)', async () => { + for (const item of [null, limitedItem({ status: 'unlisted' })]) { + wireMocks(item); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(404); + } + }); + + test('visibility filter narrows the admin list (#1110 候補の母集団)', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'ScanCommand') { + return { + Items: [ + limitedItem(), + limitedItem({ sharedId: 's2', visibility: 'public', passcode: undefined }), + limitedItem({ sharedId: 's3' }), // visibility 'limited' + ], + }; + } + return {}; + }); + const res = await handler({ + requestContext: { http: { method: 'GET', path: '/admin/shared-assignments', sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + queryStringParameters: { visibility: 'limited' }, + }); + expect(res.statusCode).toBe(200); + const { items } = JSON.parse(res.body as string); + expect(items.map((i: { sharedId: string }) => i.sharedId).sort()).toEqual(['s1', 's3']); + expect(items.every((i: { visibility: string }) => i.visibility === 'limited')).toBe(true); + }); +}); diff --git a/infra/smalruby-admin/lambda/tests/handler.test.ts b/infra/smalruby-admin/lambda/tests/handler.test.ts index e2d5c539f2d..d9cf8f3fdfd 100644 --- a/infra/smalruby-admin/lambda/tests/handler.test.ts +++ b/infra/smalruby-admin/lambda/tests/handler.test.ts @@ -509,9 +509,11 @@ describe('クラス管理 + 期限切れ復元 (issue #1084)', () => { } if (name === 'ScanCommand') { return { Items: [ + // classroom item の課題コンテンツは `assignment` 属性(`content` は + // SharedAssignments 側の名前 — 取り違えの回帰防止 #1106)。 { classroomId: 'c1', className: '5年1組', assignmentName: 'ねこ迷路ゲーム', teacherSub: 't1', status: 'active', createdAt: '2026-07-10T00:00:00.000Z', - content: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' } }, + assignment: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' } }, { classroomId: 'eval-quota#t1#2026-07-19', status: 'active' }, ] }; } diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 1253ba5d4a7..d28d53ef2cd 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -111,6 +111,8 @@ export class SmalrubyAdminStack extends cdk.Stack { SUBMISSIONS_TABLE_NAME: `ClassroomSubmissions${stageSuffix}`, GROUPS_TABLE_NAME: `ClassroomGroups${stageSuffix}`, SUBMISSIONS_BUCKET_NAME: `smalruby-classroom-submissions${stageSuffix}`, + NOTIFICATIONS_TABLE_NAME: `ClassroomNotifications${stageSuffix}`, + NOTIFICATION_TTL_DAYS: process.env.NOTIFICATION_TTL_DAYS || '180', STAGE: stage, }, bundling: { @@ -176,6 +178,14 @@ export class SmalrubyAdminStack extends cdk.Stack { groupsTable.grantReadWriteData(handlerFn); submissionsBucket.grantRead(handlerFn); + // お知らせ (notification center, EPIC #1111): this stack is the single + // WRITER of teacher notices; reads happen through the classroom API with + // the teacher's own auth. Write-only grant keeps that asymmetry honest. + const notificationsTable = dynamodb.Table.fromTableAttributes(this, 'NotificationsRef', { + tableName: `ClassroomNotifications${stageSuffix}`, + }); + notificationsTable.grantWriteData(handlerFn); + // --- Custom Domain --- const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; @@ -263,6 +273,9 @@ export class SmalrubyAdminStack extends cdk.Stack { addRoute('/admin/shared-assignments/reports', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/shared-assignments/{sharedId}', [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]); + // Admin 推薦 (#1110) + addRoute('/admin/shared-assignments/{sharedId}/recommend', + [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.DELETE]); // Classroom management + expired restore (S4 #1084). HTTP API prefers // the literal restore-candidates route over {classroomId} by specificity. @@ -273,6 +286,12 @@ export class SmalrubyAdminStack extends cdk.Stack { [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]); addRoute('/admin/classrooms/{classroomId}/restore-plan', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/{classroomId}/restore', [apigatewayv2.HttpMethod.POST]); + // 共有推奨 (#1106) + addRoute('/admin/classrooms/{classroomId}/recommend-sharing', + [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.DELETE]); + + // お知らせ送信 (notification center #1111) + addRoute('/admin/notifications', [apigatewayv2.HttpMethod.POST]); // Single-operator tool: a human never needs more than a couple of // requests per second, so throttle hard to cap the API-GW request bill an diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index f26f1bc1a91..d6b0244e7d1 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -34,6 +34,9 @@ const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-clas // みんなの課題 — nationwide shared assignment library (EPIC #1066) const SHARED_ASSIGNMENTS_TABLE = process.env.SHARED_ASSIGNMENTS_TABLE_NAME || 'SharedAssignments'; const SHARED_REPORTS_TABLE = process.env.SHARED_REPORTS_TABLE_NAME || 'SharedAssignmentReports'; +// お知らせ (notification center, EPIC #1111) — admin → teacher notices. The +// admin stack writes items; this API lists/marks-read for the teacher. +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; const SHARED_BUCKET = process.env.SHARED_BUCKET_NAME || 'smalruby-shared-assignments'; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || ''; @@ -100,6 +103,9 @@ const MAX_SUPPLEMENT_URL_LENGTH = 500; const MAX_AUTHOR_NAME_LENGTH = 30; const MAX_AUTHOR_AFFILIATION_LENGTH = 50; const MAX_SHARED_REPORT_REASON_LENGTH = 200; +// お知らせの一覧窓(= mark-read が既読化する範囲)。通知は短命な案内なので +// 直近 50 件で足りる想定(TTL は書き手の admin スタックが付ける)。 +const NOTIFICATION_LIST_LIMIT = 50; // Class (旧組) v2 data model: every assignment (Classrooms record) belongs to // a class (ClassroomGroups record), and class-level GC linkage / co-teachers / @@ -652,6 +658,9 @@ function mapClassroomSummary(item: Record, identity: TeacherIde topic: item.topic || null, sortDate: item.sortDate || item.createdAt || null, hasAssignment: hasAssignmentContent(item), + // 共有推奨 (#1106): 書き込みは admin スタックのみ。boolean へ投影する + // (recommendedForSharingBy = admin email は内部情報)。 + recommendedForSharing: !!item.recommendedForSharingAt, status: item.status, role: item.teacherSub === identity.sub ? 'owner' : 'co-teacher', }; @@ -728,6 +737,7 @@ async function handleGetClassroom(identity: TeacherIdentity, classroomId: string topic: result.Item.topic || null, sortDate: result.Item.sortDate || result.Item.createdAt || null, hasAssignment: hasAssignmentContent(result.Item), + recommendedForSharing: !!result.Item.recommendedForSharingAt, role: result.Item.teacherSub === identity.sub ? 'owner' : 'co-teacher', }), }; @@ -3631,6 +3641,9 @@ function mapSharedSummary(item: Record, opts: { includePasscode reuseCount: (item.reuseCount as number) || 0, // 公開範囲: 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。 visibility: (item.visibility as string) || 'public', + // Admin 推薦 (#1110): 書き込みは admin スタックのみ。boolean へ投影する + // (recommendedBy = admin email は内部情報なので先生側 API には出さない)。 + recommended: !!item.recommendedAt, status: item.status, createdAt: item.createdAt, updatedAt: item.updatedAt, @@ -4179,16 +4192,21 @@ async function handleUpdateSharedAssignment( if (body.licenseConsent !== true) { throw new ValidationError('licenseConsent (CC BY 4.0) is required to make it public'); } + // `??` だと body の明示的な null(= クリア)が既存値へ巻き戻る + // (例: 発展フォームでコマ数を空にしても旧値が残る)。他の per-field + // ブロックと同じ「undefined のときだけ既存値」で合成する。 Object.assign(updates, validateSharedAttributes({ - schoolLevel: body.schoolLevel ?? item.schoolLevel, - subject: body.subject ?? item.subject, - grades: body.grades ?? item.grades, - tags: body.tags ?? item.tags, - lessonCount: body.lessonCount ?? item.lessonCount, + schoolLevel: body.schoolLevel !== undefined ? body.schoolLevel : item.schoolLevel, + subject: body.subject !== undefined ? body.subject : item.subject, + grades: body.grades !== undefined ? body.grades : item.grades, + tags: body.tags !== undefined ? body.tags : item.tags, + lessonCount: body.lessonCount !== undefined ? body.lessonCount : item.lessonCount, } as Record)); Object.assign(updates, validateAuthorProfile({ - authorName: body.authorName ?? item.authorName, - authorAffiliation: body.authorAffiliation ?? item.authorAffiliation, + authorName: body.authorName !== undefined ? body.authorName : item.authorName, + authorAffiliation: body.authorAffiliation !== undefined + ? body.authorAffiliation + : item.authorAffiliation, })); updates.visibility = 'public'; } else if (body.visibility === 'limited' && currentVisibility !== 'limited') { @@ -4328,6 +4346,98 @@ async function handleReportSharedAssignment( return { statusCode: 201, body: JSON.stringify({}) }; } +// --- お知らせ (notification center, EPIC #1111) --- +// Items are written by the admin stack with this shape: +// { teacherSub, notificationId (createdAt-prefixed for chronological SK), +// type, title, body, link?, createdBy, createdAt, ttl } +// This API is deliberately read-only for teachers (list + mark-read): the +// single writer stays on the admin side, so notices cannot be forged from +// the editor. + +async function handleListNotifications( + identity: TeacherIdentity, +): Promise { + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + // notificationId starts with the ISO createdAt → newest first. + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + const notifications = (result.Items || []).map(item => ({ + notificationId: item.notificationId, + type: item.type || 'admin_message', + title: item.title, + body: item.body || null, + link: item.link || null, + readAt: item.readAt || null, + createdAt: item.createdAt, + })); + const unreadCount = notifications.filter(n => !n.readAt).length; + return { statusCode: 200, body: JSON.stringify({ notifications, unreadCount }) }; +} + +async function handleMarkNotificationsRead( + identity: TeacherIdentity, + body: Record, +): Promise { + // A top-level JSON array would read `body.notificationIds` as undefined and + // silently take the mark-all path — reject it explicitly (review finding). + if (Array.isArray(body)) { + throw new ValidationError('Request body must be a JSON object'); + } + let ids: string[]; + if (body.notificationIds === undefined || body.notificationIds === null) { + // No explicit ids = mark everything currently unread, bounded by the + // same window the list endpoint shows, so "open panel → clear badge" + // stays consistent with what the teacher actually saw. + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + ids = (result.Items || []) + .filter(item => !item.readAt) + .map(item => String(item.notificationId)); + } else if ( + Array.isArray(body.notificationIds) && + body.notificationIds.length <= NOTIFICATION_LIST_LIMIT && + // Length-bound each id so a garbage id fails as 400, not as a DynamoDB + // ValidationException → 500 (real ids are ~61 chars: ISO + '#' + UUID). + body.notificationIds.every(id => typeof id === 'string' && id.length <= 200) + ) { + ids = body.notificationIds as string[]; + } else { + throw new ValidationError( + `notificationIds must be an array of at most ${NOTIFICATION_LIST_LIMIT} strings`, + ); + } + + const now = new Date().toISOString(); + let updated = 0; + for (const notificationId of ids) { + try { + await docClient.send(new UpdateCommand({ + TableName: NOTIFICATIONS_TABLE, + // The key includes the caller's own teacherSub, so a teacher can + // never touch another teacher's rows regardless of the ids sent. + Key: { teacherSub: identity.sub, notificationId }, + UpdateExpression: 'SET readAt = if_not_exists(readAt, :now)', + // Never create phantom rows for ids that don't exist (TTL races). + ConditionExpression: 'attribute_exists(notificationId)', + ExpressionAttributeValues: { ':now': now }, + })); + updated += 1; + } catch (err) { + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + } + return { statusCode: 200, body: JSON.stringify({ updated }) }; +} + // --- Main handler --- export const handler = async (event: APIGatewayProxyEventV2): Promise => { @@ -4379,6 +4489,17 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +interface MakeEventOptions { + body?: unknown; + token?: string; +} + +const makeEvent = ( + method: string, + path: string, + { body, token }: MakeEventOptions = {}, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'http://localhost:8601', + }, + pathParameters: {}, + queryStringParameters: undefined, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const notice = (id: string, extra: Record = {}) => ({ + teacherSub: 'dev-test-teacher', + notificationId: id, + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + createdBy: 'admin@example.com', + createdAt: id.slice(0, 24), + ...extra, +}); + +describe('お知らせセンター (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + describe('GET /notifications', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('GET', '/notifications')); + expect(res.statusCode).toBe(401); + }); + + test('returns the caller-scoped inbox with unreadCount', async () => { + mockSend.mockImplementation(async (command) => { + expect(command.constructor.name).toBe('QueryCommand'); + expect(command.input.ExpressionAttributeValues[':sub']).toBe('dev-test-teacher'); + expect(command.input.ScanIndexForward).toBe(false); + return { + Items: [ + notice('2026-07-25T01:00:00.000Z#b'), + notice('2026-07-24T01:00:00.000Z#a', { readAt: '2026-07-24T02:00:00.000Z' }), + ], + }; + }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body || '{}'); + expect(data.unreadCount).toBe(1); + expect(data.notifications).toHaveLength(2); + expect(data.notifications[0]).toEqual({ + notificationId: '2026-07-25T01:00:00.000Z#b', + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + readAt: null, + createdAt: '2026-07-25T01:00:00.000Z', + }); + // Internal fields never leak to the editor. + expect(res.body).not.toContain('createdBy'); + expect(res.body).not.toContain('teacherSub'); + }); + + test('empty inbox returns zero unread', async () => { + mockSend.mockResolvedValue({ Items: [] }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ notifications: [], unreadCount: 0 }); + }); + }); + + describe('POST /notifications/mark-read', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { body: {} })); + expect(res.statusCode).toBe(401); + }); + + test('marks the given ids read, keyed to the caller', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['n1', 'n2'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates).toHaveLength(2); + expect(updates[0].Key).toEqual({ teacherSub: 'dev-test-teacher', notificationId: 'n1' }); + // Never create phantom rows; never overwrite an earlier readAt. + expect(updates[0].ConditionExpression).toContain('attribute_exists'); + expect(updates[0].UpdateExpression).toContain('if_not_exists(readAt'); + }); + + test('without ids marks everything currently unread', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'QueryCommand') { + return { + Items: [ + notice('n3'), + notice('n2', { readAt: '2026-07-24T02:00:00.000Z' }), + notice('n1'), + ], + }; + } + updates.push(command.input); + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: {}, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates.map(u => (u.Key as Record).notificationId)).toEqual(['n3', 'n1']); + }); + + test('ids that vanished (TTL race) are skipped, not errors', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + const err = new Error('conditional failed') as Error & { name: string }; + err.name = 'ConditionalCheckFailedException'; + throw err; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['gone'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 0 }); + }); + + test('400 on malformed notificationIds', async () => { + const bads = [ + 'x', + [1, 2], + Array.from({ length: 51 }, (_, i) => `n${i}`), + ['x'.repeat(201)], // overlong id → 400, not a DynamoDB 500 + ]; + for (const bad of bads) { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: bad }, + })); + expect(res.statusCode).toBe(400); + } + }); + + test('400 when the whole body is a JSON array (would silently mark all)', async () => { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: ['n1', 'n2'], + })); + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts b/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts new file mode 100644 index 00000000000..43e5a92eabc --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts @@ -0,0 +1,169 @@ +/** + * 先生側 API と Admin 推薦 (#1110) の境界のテスト。 + * + * - 一覧/詳細の投影は boolean の recommended だけ(recommendedBy は内部情報) + * - 先生の PATCH からは recommendedAt/recommendedBy を書けない(whitelist) + * - 限定公開 → 全体公開の PATCH で明示的な null(クリア)が既存値に + * 巻き戻らない(レビュー指摘の回帰テスト) + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = ( + method: string, + path: string, + pathParameters: Record, + body?: unknown, + query?: Record, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'http://localhost:8601' }, + pathParameters, + queryStringParameters: query, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const myLimitedItem = (over: Record = {}) => ({ + sharedId: 's1', + title: 'ねこあつめ入門', + summary: '説明', + content: { pages: [{ text: 'ページ1' }] }, + schoolLevel: 'junior-high', + grades: [1], + subject: '技術・家庭(技術分野)', + tags: ['甲子園'], + lessonCount: 3, + supplementUrl: null, + authorName: 'るびお', + authorAffiliation: '島根県', + authorSub: 'dev-test-teacher', + visibility: 'limited', + passcode: 'abc234', + status: 'published', + reuseCount: 0, + recommendedAt: '2026-07-21T00:00:00.000Z', + recommendedBy: 'admin@example.com', + createdAt: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-07-20T00:00:00.000Z', + ...over, +}); + +describe('先生側 API と Admin 推薦の境界 (#1110)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + test('詳細投影は recommended (boolean) のみ — recommendedBy を出さない', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') return { Item: myLimitedItem() }; + return {}; + }); + const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' })); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body || '{}'); + expect(data.recommended).toBe(true); + expect(res.body).not.toContain('recommendedBy'); + expect(res.body).not.toContain('admin@example.com'); + }); + + test('先生の PATCH は recommendedAt/recommendedBy を無視する (whitelist)', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') { + return { Item: myLimitedItem({ recommendedAt: undefined, recommendedBy: undefined }) }; + } + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + title: '改題', + recommendedAt: '2026-07-25T00:00:00.000Z', + recommendedBy: 'attacker@example.com', + })); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + const expr = String(updates[0].UpdateExpression); + expect(expr).not.toContain('recommended'); + expect(JSON.stringify(updates[0].ExpressionAttributeValues)).not.toContain('attacker'); + }); + + test('限定公開→全体公開: 明示的な null クリアが既存値へ巻き戻らない', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') return { Item: myLimitedItem() }; + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + visibility: 'public', + licenseConsent: true, + schoolLevel: 'junior-high', + subject: '技術・家庭(技術分野)', + grades: [1], + tags: ['甲子園'], + // クリア(発展フォームで空にした想定) + lessonCount: null, + authorAffiliation: null, + authorName: 'るびお', + })); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + const values = updates[0].ExpressionAttributeValues as Record; + const names = updates[0].ExpressionAttributeNames as Record | undefined; + const expr = String(updates[0].UpdateExpression); + // クリアした値が旧値 (3 / 島根県) に戻っていないこと。 + const serialized = JSON.stringify({ expr, values, names }); + expect(serialized).toContain('public'); + const lessonKey = Object.entries(values).find(([, v]) => v === 3); + expect(lessonKey).toBeUndefined(); + expect(serialized).not.toContain('島根県'); + }); + + test('一覧 (mine) 投影にも recommended が載る', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'QueryCommand') return { Items: [myLimitedItem()] }; + return {}; + }); + const res = await handler(makeEvent('GET', '/shared-assignments', {}, undefined, { mine: '1' })); + expect(res.statusCode).toBe(200); + const { items } = JSON.parse(res.body || '{}'); + expect(items[0].recommended).toBe(true); + expect(res.body).not.toContain('recommendedBy'); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 15e7f7f3913..7c5a8aaedaa 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -21,6 +21,7 @@ export class ClassroomStack extends cdk.Stack { public readonly groupsTable: dynamodb.Table; public readonly sharedAssignmentsTable: dynamodb.Table; public readonly sharedReportsTable: dynamodb.Table; + public readonly notificationsTable: dynamodb.Table; public readonly submissionsBucket: s3.Bucket; public readonly sharedBucket: s3.Bucket; public readonly api: apigatewayv2.HttpApi; @@ -332,6 +333,35 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.sharedReportsTable).add('ResourceType', 'DynamoDB'); + // --- お知らせ (notification center, EPIC #1111) --- + // Admin → teacher notices surfaced in the class management UI. This stack + // owns the table because teachers read it through this API with their + // existing ID-token auth; the admin stack imports it by the fleet's stage + // naming convention and only writes (mirror of the SharedAssignments + // arrangement — N2: the admin stack never modifies this stack). + // PK teacherSub / SK notificationId (createdAt-prefixed → chronological), + // so a single Query serves the per-teacher inbox. Notices are ephemeral + // guidance, not records: TTL is set by the writer (admin stack). + this.notificationsTable = new dynamodb.Table(this, 'NotificationsTable', { + tableName: `ClassroomNotifications${stageSuffix}`, + partitionKey: { + name: 'teacherSub', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'notificationId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: false, + }, + timeToLiveAttribute: 'ttl', + }); + + cdk.Tags.of(this.notificationsTable).add('ResourceType', 'DynamoDB'); + // --- S3 Bucket for submissions --- this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', { @@ -407,6 +437,7 @@ export class ClassroomStack extends cdk.Stack { GROUPS_TABLE_NAME: this.groupsTable.tableName, SHARED_ASSIGNMENTS_TABLE_NAME: this.sharedAssignmentsTable.tableName, SHARED_REPORTS_TABLE_NAME: this.sharedReportsTable.tableName, + NOTIFICATIONS_TABLE_NAME: this.notificationsTable.tableName, SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName, SHARED_BUCKET_NAME: this.sharedBucket.bucketName, SHARE_DAILY_LIMIT: process.env.SHARE_DAILY_LIMIT || '10', @@ -445,6 +476,11 @@ export class ClassroomStack extends cdk.Stack { this.groupsTable.grantReadWriteData(handlerFn); this.sharedAssignmentsTable.grantReadWriteData(handlerFn); this.sharedReportsTable.grantReadWriteData(handlerFn); + // お知らせは admin スタックが単一の書き手 (#1111)。この Lambda は一覧 + // (Query) と既読化 (UpdateItem) しか行わないので、PutItem を含む RW では + // なく必要最小限だけ grant して「エディタからお知らせを偽造できない」を + // IAM でも担保する(レビュー指摘)。 + this.notificationsTable.grant(handlerFn, 'dynamodb:Query', 'dynamodb:UpdateItem'); this.submissionsBucket.grantPut(handlerFn); this.submissionsBucket.grantRead(handlerFn); this.sharedBucket.grantPut(handlerFn); @@ -692,6 +728,21 @@ export class ClassroomStack extends cdk.Stack { integration, }); + // お知らせ (notification center #1111) — teacher-facing inbox. Own root + // path; writes happen from the admin stack, so this API only lists and + // marks-read for the authenticated teacher. + this.api.addRoutes({ + path: '/notifications', + methods: [apigatewayv2.HttpMethod.GET], + integration, + }); + + this.api.addRoutes({ + path: '/notifications/mark-read', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + // Groups (組) — teacher-side organizing concept. Own root path so the // /classrooms/{classroomId} patterns never shadow it. this.api.addRoutes({ diff --git a/packages/admin/src/components/app.css b/packages/admin/src/components/app.css index 988e2902790..8ecdcb7fba5 100644 --- a/packages/admin/src/components/app.css +++ b/packages/admin/src/components/app.css @@ -45,10 +45,81 @@ body { font-size: 0.75rem; } -.admin-header-email { + +/* アバターメニュー(右上固定・#1111 レビュー)。 */ +.admin-avatar-menu { + position: relative; margin-left: auto; +} + +.admin-avatar-button { + display: flex; + align-items: center; + gap: 0.25rem; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.admin-avatar-initials { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + background: white; + color: hsl(260, 60%, 50%); + font-size: 0.75rem; + font-weight: bold; +} + +.admin-avatar-caret { + color: white; + font-size: 0.6rem; + line-height: 1; +} + +.admin-avatar-popup { + position: absolute; + top: 2.6rem; + right: 0; + min-width: 13rem; + background: white; + border: 1px solid hsl(220, 15%, 85%); + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + color: hsl(220, 15%, 25%); + text-align: left; + overflow: hidden; + z-index: 100; +} + +.admin-avatar-popup-email { + padding: 0.6rem 0.9rem; + font-size: 0.75rem; + color: hsl(220, 10%, 45%); + border-bottom: 1px solid hsl(220, 15%, 92%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-avatar-popup-item { + display: block; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: none; + text-align: left; font-size: 0.85rem; - opacity: 0.9; + color: inherit; + cursor: pointer; +} + +.admin-avatar-popup-item:hover { + background: hsl(220, 20%, 96%); } .admin-main { @@ -155,7 +226,32 @@ body { margin-top: 0.75rem; } -.admin-actions button { +/* Bordered sub-section inside a detail view (お知らせ送信 #1111 など). */ +.admin-panel { + margin-top: 1.25rem; + padding: 0.75rem; + border: 1px solid hsl(220, 15%, 88%); + border-radius: 0.4rem; + background: white; +} + +.admin-panel input, +.admin-panel textarea { + display: block; + width: 100%; + max-width: 32rem; + margin: 0.4rem 0; + padding: 0.4rem; + border: 1px solid hsl(220, 15%, 75%); + border-radius: 0.4rem; + font: inherit; +} + +/* 「← 一覧に戻る」など詳細画面の戻るボタンもアクションボタンと同一ルールに + 統合して見た目を完全一致させる(font を指定すると UA 既定より大きくなり + 一回り大きく見えたため、両者で font 未指定=UA 既定に揃える)。 */ +.admin-actions button, +.admin-back-button { padding: 0.4rem 0.9rem; border: 1px solid hsl(220, 15%, 75%); border-radius: 0.4rem; @@ -163,6 +259,14 @@ body { cursor: pointer; } +.admin-back-button { + margin-bottom: 1rem; +} + +.admin-back-button:hover { + background: hsl(220, 15%, 96%); +} + .admin-confirm { display: inline-flex; align-items: center; diff --git a/packages/admin/src/components/app.jsx b/packages/admin/src/components/app.jsx index 2ad355d92c2..07753b3ef92 100644 --- a/packages/admin/src/components/app.jsx +++ b/packages/admin/src/components/app.jsx @@ -5,6 +5,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {fetchMe, setIdToken} from '../lib/admin-api.js'; import {getDevLoginToken, initGoogleSignIn} from '../lib/google-auth.js'; +import AvatarMenu from './avatar-menu.jsx'; import BugReportsView from './bug-reports-view.jsx'; import ClassroomsView from './classrooms-view.jsx'; import SharedAssignmentsView from './shared-assignments-view.jsx'; @@ -41,6 +42,13 @@ const App = () => { const handleReload = useCallback(() => window.location.reload(), []); + // トークンはモジュールメモリのみ。ログアウトはリロードで確実に破棄し + // サインイン画面へ戻す(session-expired の再読み込みと同じ手当て)。 + const handleLogout = useCallback(() => { + setIdToken(null); + window.location.reload(); + }, []); + // Google ID tokens live ~1 hour and the SPA keeps them in memory only, so // an expired session surfaces as API 401s. The clients broadcast those; // show one clear prompt instead of per-view raw errors. @@ -91,10 +99,10 @@ const App = () => {

{'Smalruby Admin'}

{me.stage} - {me.email} +