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 @@

+## 限定公開と運営の推薦(#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'` でカタログに載る
+- 推薦通知をクリックすると「自分の投稿」へ直接ジャンプする(クラス未選択ならアクティブな先頭クラスを開いてから表示)
+
+### 自分の投稿の推薦バッジ
+
+
+
+### 全体公開フォーム(編集モード)
+
+
+
## 主要ファイル
### バックエンド(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)
+
+
+
+クラス管理のタイトルバー右上(× の左隣)は **アバターメニュー**(メール頭文字 + ▼)に固定。その左に **白一色のベル**(お知らせ)を置く。運営(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)
+
+
+
+運営(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}
+
)}
{
{'このクラスはまだ存在しています。アーカイブからの復旧は先生自身のクラス管理画面から行えます。'}
{
return (
{
+ if (typeof value === 'undefined' || value === null || value === '') return UNSPECIFIED;
+ return value;
+};
+
const StatusBadge = ({status}) => (
{status === 'published' ? '公開中' : '非公開'}
@@ -20,10 +30,35 @@ const StatusBadge = ({status}) => (
StatusBadge.propTypes = {status: PropTypes.string};
+// 公開範囲 (#1109) と推薦 (#1110) のバッジ。限定公開は合言葉でしか届かない
+// 内輪公開なので、運営が推薦候補を見分けられるよう明示する。
+const VisibilityBadge = ({visibility}) =>
+ (visibility === 'limited' ? (
+ {'限定公開'}
+ ) : null);
+
+VisibilityBadge.propTypes = {visibility: PropTypes.string};
+
+const RecommendedBadge = ({recommended}) =>
+ (recommended ? (
+ {'推薦中'}
+ ) : null);
+
+RecommendedBadge.propTypes = {recommended: PropTypes.bool};
+
const SharedDetail = ({sharedId, onBack, onChanged}) => {
const [detail, setDetail] = useState(null);
const [error, setError] = useState('');
const [confirming, setConfirming] = useState(false);
+ // 推薦 (#1110) は非公開化とは別の確認ステップを持つ(誤操作防止のため
+ // 同時にどちらか一方しか arm できない)。
+ const [confirmingRecommend, setConfirmingRecommend] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
@@ -32,7 +67,10 @@ const SharedDetail = ({sharedId, onBack, onChanged}) => {
.catch(err => setError(err.message));
}, [sharedId]);
- const handleArm = useCallback(() => setConfirming(true), []);
+ const handleArm = useCallback(() => {
+ setConfirmingRecommend(false);
+ setConfirming(true);
+ }, []);
const handleDisarm = useCallback(() => setConfirming(false), []);
const handleFlip = useCallback(async () => {
if (!detail) return;
@@ -51,6 +89,32 @@ const SharedDetail = ({sharedId, onBack, onChanged}) => {
}
}, [detail, sharedId, onChanged]);
+ const handleArmRecommend = useCallback(() => {
+ setConfirming(false);
+ setConfirmingRecommend(true);
+ }, []);
+ const handleDisarmRecommend = useCallback(() => setConfirmingRecommend(false), []);
+ const handleFlipRecommend = useCallback(async () => {
+ if (!detail) return;
+ setBusy(true);
+ setError('');
+ try {
+ const updated = await setSharedRecommendation(sharedId, !detail.recommended);
+ setDetail(prev => ({
+ ...prev,
+ recommended: updated.recommended,
+ recommendedAt: updated.recommendedAt,
+ recommendedBy: updated.recommendedBy
+ }));
+ setConfirmingRecommend(false);
+ onChanged();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ }, [detail, sharedId, onChanged]);
+
if (error) {
return ( {
return (
{
{detail.title}
{' '}
+ {' '}
+
+ {' '}
+
- {`© ${detail.authorName}${detail.authorAffiliation ? `(${detail.authorAffiliation})` : ''} / CC BY 4.0`}
- {` ・ 取り込み ${detail.reuseCount} 回`}
+ {`© ${orUnspecified(detail.authorName)}`}
+ {detail.authorAffiliation ? `(${detail.authorAffiliation})` : ''}
+ {` / CC BY 4.0 ・ 取り込み ${detail.reuseCount} 回`}
- {`${detail.schoolLevel} / ${detail.subject} / タグ: ${(detail.tags || []).join(', ') || '-'}`}
+ {`${orUnspecified(detail.schoolLevel)} / ${orUnspecified(detail.subject)}`}
+ {` / タグ: ${(detail.tags || []).join(', ') || UNSPECIFIED}`}
{detail.supplementUrl ? (
{
{detail.status === 'published' ? '非公開にする' : '再公開する'}
)}
+ {' '}
+ {/* 推薦は限定公開のみ(サーバー側も同じ制約)。ただし推薦済みなら
+ 公開に広がった後でも取り消しは出す。 */}
+ {detail.status === 'published' && (detail.visibility === 'limited' || detail.recommended) ? (
+ confirmingRecommend ? (
+
+ {detail.recommended ?
+ '推薦を取り消しますか?(先生には通知されません)' :
+ 'この課題を推薦しますか?(作成した先生にお知らせが届きます)'}
+ {'実行'}
+ {'やめる'}
+
+ ) : (
+
+ {detail.recommended ? '推薦を取り消す' : '推薦する'}
+
+ )
+ ) : null}
);
@@ -181,7 +288,8 @@ const SharedAssignmentsView = () => {
.then(data => setQueue(data.queue || []))
.catch(err => setError(err.message));
} else {
- fetchSharedAssignments()
+ // 限定公開タブ (#1110) は推薦候補の母集団: visibility で絞る。
+ fetchSharedAssignments(tab === 'limited' ? {visibility: 'limited'} : {})
.then(data => setItems(data.items || []))
.catch(err => setError(err.message));
}
@@ -197,6 +305,10 @@ const SharedAssignmentsView = () => {
setTab('all');
setSelectedId(null);
}, []);
+ const handleTabLimited = useCallback(() => {
+ setTab('limited');
+ setSelectedId(null);
+ }, []);
const handleOpen = useCallback(e => setSelectedId(e.currentTarget.dataset.sharedId), []);
const handleBack = useCallback(() => setSelectedId(null), []);
@@ -225,6 +337,12 @@ const SharedAssignmentsView = () => {
type="button"
onClick={handleTabAll}
>{'すべての投稿'}
+
{'限定公開'}
{error ? {
>
{item.title}
+
+
- {`${item.authorName} ・ 取り込み ${item.reuseCount} 回 ・ ${item.createdAt}`}
+ {`${orUnspecified(item.authorName)} ・ 取り込み ${item.reuseCount} 回`}
+ {` ・ ${item.createdAt}`}
diff --git a/packages/admin/src/lib/admin-api.js b/packages/admin/src/lib/admin-api.js
index df53f11cc06..8d730903d9b 100644
--- a/packages/admin/src/lib/admin-api.js
+++ b/packages/admin/src/lib/admin-api.js
@@ -79,13 +79,14 @@ const fetchSharedReports = () => request('GET', '/admin/shared-assignments/repor
/**
* Fleet-wide shared assignment list.
- * @param {object} [filters] - {status?, q?}
+ * @param {object} [filters] - {status?, q?, visibility?}
* @returns {Promise