diff --git a/docs/assignment-sharing/README.md b/docs/assignment-sharing/README.md new file mode 100644 index 0000000000..8305a63888 --- /dev/null +++ b/docs/assignment-sharing/README.md @@ -0,0 +1,87 @@ +# みんなの課題(共有課題ライブラリ) + +> **🆕 Smalruby 独自** — upstream に存在しない、Smalruby のために新規追加された機能 + +先生が自分で作成した課題(説明ページ + スタータープロジェクト + 補足資料 URL)を**インターネットを通じて全国の先生に共有し、再利用できる**仕組み。他の先生は共有された課題を閲覧・絞り込みし、自分のクラスに取り込むとスタータープロジェクトごと読み込まれ、そのまま授業を進められる。 + +設計の正典: EPIC #1066 / スパイク #1067 の Decision Log(D1〜D12)。 + +## 目次 + +| ドキュメント | 内容 | +|-------------|------| +| 本 README | 機能全体像・主要ファイル・設定 | +| [運用手順書](operations.md) | 通報対応(モデレーション)の運用者向け手順 | +| [../classroom/architecture.md](../classroom/architecture.md) | API ルート・データモデル(「みんなの課題」セクション) | +| [../classroom/testing.md](../classroom/testing.md) | data-testid 一覧(`shared-*`) | + +## 全体像 + +```text +先生A(共有する側) 先生B(使う側) +課題詳細 →「この課題を共有」 課題ボード →「みんなの課題からさがす」 + タイトル・属性(学校種×学年×教科×タグ) カタログ(新着順・絞り込み) + 補足資料URL(指導案など・https のみ) → 詳細(説明ページ・© 表示名 / CC BY 4.0) + 表示名・所属 / CC BY 4.0 同意 「このクラスに取り込む」→ 授業開始 +``` + +- **閲覧・投稿とも先生ログイン必須**(D1) +- **ライセンスは CC BY 4.0 に統一**(D2): 投稿時に同意、クレジットは表示名。取り込み後の改変・授業利用は自由 +- **共有はスナップショット**: 投稿後にクラス側の課題を変えても共有側は変わらない(更新は「自分の投稿」から上書き、D10)。取り込みも独立コピー(元の更新に追従しない) +- **共有データは保存期限(TTL)の対象外**(D7・永続)。クラス機能の 90 日 TTL とは分離 +- **プロフィールは最小限**(D6): 表示名(必須)+ 所属表記(任意)のみ。メール・実名は保持しない +- **補足資料 URL**(D4): https のみ。入力時に「学習指導案・授業スライドなど授業の進め方がわかる資料(Google ドライブ / ドキュメントの閲覧リンク推奨)」と明示し、閲覧側は外部ドメイン名付きの確認を挟む +- **モデレーションは事後対応型**(D3): 通報 → 運用 CLI で unpublish([operations.md](operations.md))。Admin SPA(EPIC #1073)完成後は管理画面に移行 + +## 画面 + +### 共有フォーム(課題詳細 →「この課題を共有」) + +![共有フォーム](screenshots/0101-share-form.png) + +### カタログ(課題ボード →「みんなの課題からさがす」) + +![カタログ](screenshots/0102-catalog.png) + +### 詳細プレビュー(クレジット・補足資料リンク・取り込み) + +![詳細プレビュー](screenshots/0103-detail.png) + +### 取り込み完了(ボードに新しい課題として出現) + +![取り込み完了](screenshots/0104-imported.png) + +## 主要ファイル + +### バックエンド(infra/smalruby-classroom) + +| ファイル | 役割 | +|---------|------| +| `lambda/handler.ts` | `/shared-assignments` 系 7 エンドポイント(共有・一覧・詳細・取り込み・更新・取り下げ・通報)、タクソノミ/URL/プロフィールのバリデータ、`buildSharedSnapshot` | +| `lambda/shared-admin-lib.ts` | 通報対応 CLI の純粋ロジック | +| `bin/shared-assignments-admin.ts` | 通報対応 CLI(dry-run 既定) | +| `lib/classroom-stack.ts` | `SharedAssignments`(TTL なし・prod RETAIN + PITR・GSI×2)/ `SharedAssignmentReports`(TTL 90日)/ 専用バケット(lifecycle なし) | + +### フロントエンド(packages/scratch-gui) + +| ファイル | 役割 | +|---------|------| +| `src/components/classroom-modal/shared-assignment-form.jsx` | 共有フォーム(属性・URL ガイダンス・CC BY 同意) | +| `src/components/classroom-modal/shared-assignment-catalog.jsx` | カタログ(一覧・絞り込み・詳細・取り込み・自分の投稿・通報) | +| `src/containers/use-shared-assignments.js` | 共有/カタログの状態管理フック | +| `src/lib/shared-assignment-taxonomy.js` | 学校種×教科の語彙(サーバーのミラー)+ parseTags | +| `src/lib/shared-author-profile.js` | プロフィールの localStorage 永続化 | +| `src/lib/classroom-api.js` | API クライアント(shared 系 7 メソッド) | + +## 設定・データ永続化 + +| 種別 | キー / 変数 | 内容 | +|------|------------|------| +| localStorage | `smalruby:sharedAuthorProfile` | 表示名・所属表記の記憶 | +| env(Lambda) | `SHARE_DAILY_LIMIT`(既定 10)/ `REPORT_DAILY_LIMIT`(既定 20) | 1日あたりの共有 / 通報回数制限 | +| env(Lambda) | `SHARED_STARTER_MAX_BYTES`(既定 50MB) | 共有スターターの容量上限 | + +## テスト + +- unit: `lambda/tests/handler-shared-assignments.test.ts`(API 25件)/ `shared-admin-lib.test.ts`(CLI 6件)/ GUI `shared-assignment-form/catalog/taxonomy/author-profile`(24件) +- E2E: `tools/playwright-verify/verify-assignment-sharing.mjs`(共有 → カタログ → 取り込み → 自分の投稿の通し。`LOCALE=ja-JP` で日本語スクリーンショット) diff --git a/docs/assignment-sharing/operations.md b/docs/assignment-sharing/operations.md new file mode 100644 index 0000000000..0fb44b66ce --- /dev/null +++ b/docs/assignment-sharing/operations.md @@ -0,0 +1,64 @@ +# みんなの課題 運用手順書(通報対応) + +> **🆕 Smalruby 独自** — みんなの課題(共有課題ライブラリ、EPIC #1066)の通報対応を行う運用者向け手順。 + +対象読者: AWS クレデンシャル(SSO)を持つ運用者。設計の背景は EPIC #1066 / スパイク #1067 の Decision Log(D3: 事後対応型モデレーション)。 + +> **将来**: Admin SPA(EPIC #1073)が完成したら通報対応は管理画面に移行する。本 CLI はそれまでの最小ツール兼フォールバック。 + +## モデレーション方針 + +- **事後対応型**: 投稿は即公開(投稿には先生ログイン + CC BY 4.0 同意が必須)。先生からの通報を受けて運用者が確認・対応する +- **物理削除はしない**: 対応は `unpublish`(`status: 'unlisted'` = カタログから非表示)。誤対応は `republish` で復元できる +- **投稿者への連絡手段は保持していない**(D6: 個人情報最小化のため email を持たない)。対応は非公開化のみで完結させる +- 通報レコードは 90 日で自動削除(TTL)。通報者の識別子(reporterSub)は悪用対策の内部データで、CLI にも表示されない + +## 前提条件 + +```bash +cd infra/smalruby-classroom + +# ステージを .env symlink で選択(deploy と同じ流儀) +ls -la .env # -> .env.stg または .env.prod + +# AWS クレデンシャル(コンテナ内 SSO) +aws sso login --sso-session smalruby --use-device-code # 失効時のみ +export AWS_PROFILE=smalruby AWS_REGION=ap-northeast-1 +eval "$(aws configure export-credentials --profile smalruby --format env)" +``` + +## 通報対応フロー + +```text +1. 通報キューを確認 + npx ts-node bin/shared-assignments-admin.ts list-reports + → 通報の多い投稿から順に、タイトル・status・通報理由が並ぶ + +2. 内容を確認 + npx ts-node bin/shared-assignments-admin.ts show + → 説明ページ全文・補足 URL・投稿者表示名・属性を表示 + (補足 URL の先はブラウザで確認。リンク先は投稿者管理のため慎重に) + +3. 判断 + ├─ 問題あり(下記基準)→ 非公開化: + │ npx ts-node bin/shared-assignments-admin.ts unpublish # dry-run + │ npx ts-node bin/shared-assignments-admin.ts unpublish --apply # 実行 + └─ 問題なし → 対応不要(通報は 90 日で自動消滅) + +4. 誤対応の復元 + npx ts-node bin/shared-assignments-admin.ts republish --apply +``` + +## 非公開化の判断基準(目安) + +| 該当 | 対応 | +|------|------| +| 個人情報(生徒名・学校の内部情報等)が含まれる | 即 unpublish | +| 補足 URL が課題と無関係・不適切なサイト | 即 unpublish | +| 著作権侵害の疑い(第三者の教材の丸写し等) | unpublish して様子見 | +| 授業として低品質・単なる好みの問題 | 対応しない(通報理由に返答する術はない) | + +## 補足 + +- 取り込み済みの課題は各先生のクラス内のスナップショットなので、**unpublish しても既に取り込んだ先生には影響しない** +- 投稿の実体(S3 `shared/{sharedId}/`)は unpublish でも残る(復元可能性のため)。完全削除が必要な法的要請の場合のみ AWS コンソールで S3 オブジェクトと DynamoDB アイテムを手動削除する diff --git a/docs/assignment-sharing/screenshots/0101-share-form.png b/docs/assignment-sharing/screenshots/0101-share-form.png new file mode 100644 index 0000000000..939d3c408f Binary files /dev/null and b/docs/assignment-sharing/screenshots/0101-share-form.png differ diff --git a/docs/assignment-sharing/screenshots/0102-catalog.png b/docs/assignment-sharing/screenshots/0102-catalog.png new file mode 100644 index 0000000000..d48b1e2ec7 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0102-catalog.png differ diff --git a/docs/assignment-sharing/screenshots/0103-detail.png b/docs/assignment-sharing/screenshots/0103-detail.png new file mode 100644 index 0000000000..11e6fcaad3 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0103-detail.png differ diff --git a/docs/assignment-sharing/screenshots/0104-imported.png b/docs/assignment-sharing/screenshots/0104-imported.png new file mode 100644 index 0000000000..cf836b47cb Binary files /dev/null and b/docs/assignment-sharing/screenshots/0104-imported.png differ diff --git a/docs/classroom/README.md b/docs/classroom/README.md index 653f65784a..cc3a57ad72 100644 --- a/docs/classroom/README.md +++ b/docs/classroom/README.md @@ -31,6 +31,7 @@ Smalruby Classroom は、日本の学校の授業で Smalruby を使うための | [テスト](testing.md) | data-testid 一覧、Playwright / 結合テスト | | [Microsoft 認証](microsoft-authentication.md) | MSAL.js 統合、サイレント再認証、Azure Portal 設定 | | [運用手順書](operations.md) | 「消えたクラスを復旧してほしい」問い合わせ対応(アーカイブ復元の案内 / 期限切れ復元スクリプト) | +| [みんなの課題](../assignment-sharing/README.md) | 全国の先生と課題を共有・再利用する機能(別 feature ディレクトリ) | ![Smalruby メニューバーの「クラス」ボタン](screenshots/0101-menu-bar.png) diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index ac14eb2e61..7e0bfe417e 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -128,6 +128,9 @@ sequenceDiagram | **DynamoDB** | `ClassroomMemberships-{stage}` | メンバー (生徒) 情報(Streams: OLD_IMAGE) | | **DynamoDB** | `ClassroomSubmissions-{stage}` | 提出情報(Streams: OLD_IMAGE) | | **DynamoDB** | `ClassroomGroups-{stage}` | クラス(学級)情報(Streams: OLD_IMAGE) | +| **DynamoDB** | `SharedAssignments-{stage}` | みんなの課題(TTL なし・prod RETAIN + PITR) | +| **DynamoDB** | `SharedAssignmentReports-{stage}` | みんなの課題の通報(TTL 90日) | +| **S3** | `smalruby-shared-assignments-{stage}` | 共有課題のスナップショット(lifecycle なし = 永続・prod RETAIN) | | **S3** | `smalruby-classroom-submissions-{stage}` | 提出ファイル (.sb3, サムネイル, スクリーンショット) + `ddb-archive/` スナップショット。lifecycle = `ARCHIVE_RETENTION_DAYS`(既定 365 日) | | **Route53** | A レコード | カスタムドメイン | | **ACM** | SSL 証明書 | HTTPS | @@ -185,6 +188,27 @@ sequenceDiagram | `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回/時) | +### みんなの課題 (共有課題ライブラリ・先生 ID Token 認証) + +全国の先生が課題(説明ページ + スターター + 補足資料 URL)を共有・再利用する機能(EPIC #1066。設計の正典は spike #1067)。 + +| Method | Path | 説明 | +|--------|------|------| +| `POST` | `/shared-assignments` | 課題を共有。`visibility`(省略時 `public`)で公開範囲を選ぶ。`public`=みんなの課題カタログ(CC BY 4.0 同意・属性・著者名 必須)。`limited`=合言葉限定公開(#1109。同意・属性・著者名は任意で、参加コード同型の合言葉を発行)。スターター 50MB 上限・10件/日制限 | +| `GET` | `/shared-assignments` | カタログ一覧(新着順・`schoolLevel`/`subject`/`grade`/`tag` で絞り込み・`cursor` ページネーション・`mine=1` で自分の投稿一覧)。**公開カタログは限定公開を除外**。`mine=1` は自分の合言葉を含む | +| `GET` | `/shared-assignments/{id}` | 詳細(ページ・画像/スターターの presigned URL・投稿者表示名。authorSub は返さない)。**限定公開は sharedId を知っていても非著者には 404**(合言葉ルックアップ経由でのみアクセス) | +| `POST` | `/shared-assignments/lookup` | 合言葉プレビュー(#1109)。`{passcode}` → summary(**sharedId は返さない**) | +| `POST` | `/shared-assignments/import-by-passcode` | 合言葉で取り込み(#1109)。`{passcode, groupId, assignmentName?}` → 自分のクラスに課題として取り込み。sharedId を露出しない内輪取り込み | +| `POST` | `/shared-assignments/{id}/import` | 自分のクラス(groupId)に課題として取り込み(**全体公開のみ**。限定公開は import-by-passcode を使う)。S3 逆コピー + reuseCount 増分 | +| `PATCH` | `/shared-assignments/{id}` | 更新(投稿者本人のみ。メタデータ + `classroomId` 指定で内容の再スナップショット=上書き)。**`visibility` を `limited`→`public` に広げる時は CC BY 同意・属性・著者名を必須化** | +| `DELETE` | `/shared-assignments/{id}` | 取り下げ = `status: 'unlisted'`(物理削除しない。本人のみ) | +| `POST` | `/shared-assignments/{id}/report` | 通報(理由必須・20件/日制限。reporterSub は内部保持のみ) | + +- **公開範囲(#1109)**: 項目は `visibility`(`public`/`limited`)を持つ。#1109 以前の項目は属性を持たず `public` とみなす(後方互換)。`limited` は `passcode`(合言葉)を持ち、公開カタログには出ない。「限定公開(合言葉・内輪)→ Admin が把握 → 推薦 → 全体公開」パイプラインの土台 +- データ: `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 サーバー側コピー(クロスバケット) + ### 生徒用 (認証不要 / Session Token) | Method | Path | 認証 | 説明 | diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index baf1f3582c..ebb126a5d2 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -242,6 +242,40 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `classroom-board-download-class` | button | ボードの「全課題の提出物をダウンロード」(active + アーカイブ済みを 1 つの zip に) | | `classroom-retention-banner` | div | 課題詳細の保存期限アラートバナー(30 日以下で表示) | | `classroom-retention-banner-download` | button | バナー内の「全作品ダウンロード」 | +| `classroom-share-assignment` | button | 課題詳細の「この課題を共有」(みんなの課題フォームを開く) | +| `shared-form` | form | みんなの課題の共有フォーム | +| `shared-form-title` / `shared-form-summary` | input | タイトル / 短い説明 | +| `shared-form-level` | select | 学校種 | +| `shared-form-subject` | select | 教科(制御語彙。学校種=その他のときは `shared-form-subject-free` input) | +| `shared-form-grade-{n}` | checkbox | 対象学年 | +| `shared-form-tags` | input | タグ(カンマ区切り・最大5) | +| `shared-form-lesson-count` | input | 想定コマ数 | +| `shared-form-url` | input | 補足資料 URL(https のみ。ガイダンス=`shared-form-url-hint`、エラー=`shared-form-url-error`) | +| `shared-form-author-name` / `shared-form-author-affiliation` | input | 表示名 / 所属表記(localStorage 記憶) | +| `shared-form-consent` | checkbox | CC BY 4.0 同意(未チェックだと送信不可) | +| `shared-form-submit` / `shared-form-cancel` | button | 共有する / キャンセル | +| `shared-form-success` | p | 公開完了メッセージ(© 表示名 / CC BY 4.0) | +| `classroom-board-shared-catalog` | button | ボードの「みんなの課題からさがす」 | +| `shared-catalog` | div | みんなの課題カタログ(ボード内に展開) | +| `shared-catalog-close` | button | カタログを閉じる | +| `shared-catalog-tab-all` / `shared-catalog-tab-mine` | button | すべて / 自分の投稿 タブ | +| `shared-catalog-filter-level/subject/grade/tag` | select/input | 絞り込み(学校種・教科・学年・タグ) | +| `shared-catalog-filter-apply` | button | 絞り込み実行 | +| `shared-catalog-list` / `shared-catalog-item-{id}` / `shared-catalog-open-{id}` | ul/li/button | カード一覧(属性バッジ・投稿者・取り込み回数) | +| `shared-catalog-load-more` | button | 次ページ読み込み(cursor があるときのみ) | +| `shared-catalog-empty` | p | 空メッセージ | +| `shared-catalog-detail` | div | 詳細プレビュー | +| `shared-detail-close` | button | 一覧に戻る | +| `shared-detail-credit` | p | 「© 表示名(所属) / CC BY 4.0」クレジット行 | +| `shared-detail-starter` | p | スターター付きの説明 | +| `shared-detail-url` | button | 補足資料リンク(クリックで確認表示) | +| `shared-detail-url-confirm` / `shared-detail-url-open` / `shared-detail-url-cancel` | span/a/button | 外部ドメイン名付き確認 →「開く」(rel=noopener・新規タブ) | +| `shared-detail-import` | button | このクラスに取り込む(published のみ表示) | +| `shared-detail-report` | button | 通報フォームを開く(他人の投稿のみ) | +| `shared-report-form` / `shared-report-reason` / `shared-report-submit` | div/textarea/button | 通報理由(必須)と送信 | +| `shared-report-sent` | p | 通報完了メッセージ | +| `shared-detail-unlist` / `shared-detail-republish` | button | 自分の投稿の取り下げ / 再公開 | +| `shared-import-success` | p | 取り込み完了メッセージ(ボード上) | | `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) | | `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク | | `classroom-board-create-name` / `classroom-board-create-submit` | input / button | インライン課題作成(課題名のみ) | diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index c2a3ab3a81..94b5f2867a 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -149,6 +149,7 @@ Google または Microsoft アカウントでサインインする画面。先 ![アーカイブ済み課題の一覧と復元](screenshots/0217-board-archived-section.png) - **残り日数バッジ**: 保存期限(自動削除)まで 30 日以下の課題行に「あと{days}日」バッジを表示(7 日以下は警告色)。閾値の根拠は EPIC #1049 の D8 - **全課題の提出物をダウンロード**(`classroom-board-download-class`): クラス内の全課題(アーカイブ済み含む — どちらも保存期限で消えるため)の提出物を 1 つの zip(`課題名/席番号_名前/作品.sb3` + サムネ/スクショ + `提出状況.csv`)でダウンロード。進捗は「n/m」表示 +- **みんなの課題からさがす**(`classroom-board-shared-catalog`、EPIC #1066): 全国の先生が共有した課題のカタログをボード内に展開。学校種・教科・学年・タグで絞り込み → 詳細プレビュー(説明ページ・「© 表示名 / CC BY 4.0」クレジット・補足資料リンクは外部ドメイン名付き確認を挟んで新規タブ)→「このクラスに取り込む」でスターターごと課題として複製(新しい参加コードが発行される)。「自分の投稿」タブから取り下げ / 再公開。他人の投稿には通報(理由必須) ![残り日数バッジと全課題ダウンロード](screenshots/0213-board-expiry-badge-download.png) - 主な data-testid: `classroom-board` / `classroom-board-create[-name|-submit]` / `classroom-board-reuse[-view|-filter|-copy-{id}]` / `classroom-board-section-{topic|none}` / `classroom-board-row|open|topic|date-{classroomId}` / `classroom-topic-add[-input]` / `classroom-topic-chip|rename|remove-{topic}` / `classroom-breadcrumbs` / `classroom-board-archived-[section|toggle|list]` / `classroom-board-archived-row-{classroomId}` / `classroom-board-restore-{classroomId}` @@ -160,6 +161,7 @@ Google または Microsoft アカウントでサインインする画面。先 課題をひらくと「説明」タブが最初に表示される。左に生徒へ表示する説明・画像・スターターの編集フォーム、**右ペインに生徒視点プレビュー**(編集内容をライブ表示・ページ送り。生徒への反映は保存時のみ)。 - 出席・提出のポーリング(30秒)は**メンバータブ表示中のみ**(費用抑制) +- **この課題を共有**(`classroom-share-assignment`): 課題(説明ページ + スターター)を「みんなの課題」(全国の先生の共有ライブラリ、EPIC #1066)に公開するフォームを開く。属性(学校種・学年・教科・タグ・コマ数)、補足資料 URL(https のみ + 期待内容のガイダンス表示)、表示名・所属(localStorage 記憶)、**CC BY 4.0 同意チェック必須**。公開後は「© 表示名 / CC BY 4.0」付きの完了メッセージを表示 - 課題の所属クラス変更・人数編集・課題単位の共同管理者・複製は**できない**(クラス設定 / 課題一覧の再利用へ集約)。フッターのボタンは「**課題をアーカイブ**」(soft-delete。ボードの「アーカイブ済みの課題」からいつでも復元可能。testid は歴史的経緯で `classroom-delete-classroom` のまま) - 主な data-testid: `classroom-tab-description` / `classroom-description-editor` / `classroom-description-preview[-body|-prev|-next]` / `classroom-tab-members` diff --git a/infra/smalruby-classroom/.env.example b/infra/smalruby-classroom/.env.example index 49db412507..5b2d879550 100644 --- a/infra/smalruby-classroom/.env.example +++ b/infra/smalruby-classroom/.env.example @@ -35,3 +35,7 @@ CORS_ALLOWED_ORIGINS=https://smalruby.app,https://smalruby.jp,http://localhost:8 # 期限切れ復元用の長期保持日数(S3 提出物 + DynamoDB 削除スナップショット。CLASSROOM_TTL_DAYS 以上が必須) ARCHIVE_RETENTION_DAYS=365 + +# みんなの課題 (共有課題ライブラリ) のレート制限 (省略時 10 / 20) +SHARE_DAILY_LIMIT=10 +REPORT_DAILY_LIMIT=20 diff --git a/infra/smalruby-classroom/bin/shared-assignments-admin.ts b/infra/smalruby-classroom/bin/shared-assignments-admin.ts new file mode 100644 index 0000000000..97224c8f21 --- /dev/null +++ b/infra/smalruby-classroom/bin/shared-assignments-admin.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env npx ts-node +/** + * Operator CLI: みんなの課題 moderation (issue #1071, EPIC #1066 D3). + * + * Post-moderation model: teachers report items; operators review the queue + * and unpublish (never hard-delete — a mistaken action stays recoverable). + * Mutations are dry-run by default; re-run with --apply to execute. + * + * Usage (from infra/smalruby-classroom, with AWS credentials for the stage): + * npx ts-node bin/shared-assignments-admin.ts list-reports + * npx ts-node bin/shared-assignments-admin.ts show + * npx ts-node bin/shared-assignments-admin.ts unpublish [--apply] + * npx ts-node bin/shared-assignments-admin.ts republish [--apply] + * + * The stage comes from the .env symlink (STAGE), same as cdk deploy. + * Full runbook: docs/assignment-sharing/operations.md + */ +import 'dotenv/config'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, ScanCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { + groupReports, + parseAdminArgs, + renderReportQueue, + renderSharedItem, + type ReportRecord, +} from '../lambda/shared-admin-lib'; + +const stage = process.env.STAGE || 'stg'; +const suffix = stage === 'prod' ? '' : `-${stage}`; +const SHARED_TABLE = `SharedAssignments${suffix}`; +const REPORTS_TABLE = `SharedAssignmentReports${suffix}`; + +const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +async function getItem(sharedId: string): Promise | null> { + const result = await docClient.send(new GetCommand({ + TableName: SHARED_TABLE, + Key: { sharedId }, + })); + return (result.Item as Record) || null; +} + +async function listReports(): Promise { + // Reports are 90-day-TTL'd and low-volume; a scan is fine. + const items: ReportRecord[] = []; + let lastKey: Record | undefined; + do { + const page = await docClient.send(new ScanCommand({ + TableName: REPORTS_TABLE, + ExclusiveStartKey: lastKey, + })); + for (const item of page.Items || []) { + items.push({ + sharedId: String(item.sharedId), + reason: String(item.reason || ''), + createdAt: String(item.createdAt || ''), + }); + } + lastKey = page.LastEvaluatedKey as Record | undefined; + } while (lastKey); + + const grouped = groupReports(items); + const itemsById = new Map>(); + for (const sharedId of grouped.keys()) { + const item = await getItem(sharedId); + if (item) itemsById.set(sharedId, item); + } + for (const line of renderReportQueue(grouped, itemsById)) { + console.log(line); + } +} + +async function setStatus(sharedId: string, status: 'published' | 'unlisted', apply: boolean): Promise { + const item = await getItem(sharedId); + if (!item) { + console.log(`スナップショットが見つかりません: ${sharedId}`); + return 1; + } + console.log(`対象: ${item.title} (現在 status=${item.status}) → ${status}`); + if (!apply) { + console.log('dry-run のため変更していません。実行するには --apply を付けてください。'); + return 0; + } + await docClient.send(new UpdateCommand({ + TableName: SHARED_TABLE, + Key: { sharedId }, + UpdateExpression: 'SET #status = :status, updatedAt = :now', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { ':status': status, ':now': new Date().toISOString() }, + })); + console.log('変更しました。'); + return 0; +} + +async function main(): Promise { + const args = parseAdminArgs(process.argv.slice(2)); + console.log(`stage=${stage} table=${SHARED_TABLE} mode=${args.apply ? 'APPLY' : 'dry-run'}`); + + switch (args.command) { + case 'list-reports': + await listReports(); + return 0; + case 'show': { + const item = await getItem(args.sharedId as string); + if (!item) { + console.log(`スナップショットが見つかりません: ${args.sharedId}`); + return 1; + } + for (const line of renderSharedItem(item)) { + console.log(line); + } + return 0; + } + case 'unpublish': + return setStatus(args.sharedId as string, 'unlisted', args.apply); + case 'republish': + return setStatus(args.sharedId as string, 'published', args.apply); + } +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }, +); diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 5daef6048e..f26f1bc1a9 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -10,7 +10,14 @@ import { UpdateCommand, BatchWriteCommand, } from '@aws-sdk/lib-dynamodb'; -import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + CopyObjectCommand, + HeadObjectCommand, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { OAuth2Client } from 'google-auth-library'; import { createRemoteJWKSet, jwtVerify } from 'jose'; @@ -24,6 +31,10 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis const KICK_REQUESTS_TABLE = process.env.KICK_REQUESTS_TABLE_NAME || 'ClassroomKickRequests'; const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups'; const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions'; +// みんなの課題 — 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'; +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 || ''; const DEV_BYPASS_TOKEN = process.env.DEV_BYPASS_TOKEN || ''; @@ -73,6 +84,23 @@ const MAX_GROUP_NAME_LENGTH = 50; // How many prior lessons of the same group to inspect when looking up the // student's previous returned comment on join (personalized recap). const PREVIOUS_COMMENT_LOOKBACK = 3; +// みんなの課題 (EPIC #1066): shared items are permanent (no TTL), so quota +// counters reuse the Classrooms table's reserved key space (TTL-cleaned) +// instead. Retention decisions: spike #1067 D10-D12. +const SHARE_DAILY_LIMIT = parseInt(process.env.SHARE_DAILY_LIMIT || '10', 10); +const REPORT_DAILY_LIMIT = parseInt(process.env.REPORT_DAILY_LIMIT || '20', 10); +const SHARED_STARTER_MAX_BYTES = parseInt(process.env.SHARED_STARTER_MAX_BYTES || String(50 * 1024 * 1024), 10); +const SHARED_REPORT_TTL_SECONDS = 90 * 24 * 60 * 60; +const SHARED_LIST_PAGE_SIZE = 30; +const MAX_SHARED_TITLE_LENGTH = 50; +const MAX_SHARED_SUMMARY_LENGTH = 100; +const MAX_SHARED_TAGS = 5; +const MAX_SHARED_TAG_LENGTH = 20; +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; + // Class (旧組) v2 data model: every assignment (Classrooms record) belongs to // a class (ClassroomGroups record), and class-level GC linkage / co-teachers / // studentCount are authoritative. The version is stamped on each group record @@ -2479,9 +2507,52 @@ async function handleUpdateGroup(identity: TeacherIdentity, groupId: string, bod ReturnValues: 'ALL_NEW', })); + // The class's studentCount is authoritative, so a change must flow down to + // its existing assignments (classrooms) — otherwise growing/shrinking the + // class leaves old assignments on their creation-time snapshot. Increasing + // adds seats (safe); decreasing drops seats — the teacher UI warns first + // that submissions on the removed seats stop showing. + if (updates.studentCount !== undefined) { + await propagateStudentCountToClassrooms( + String((result.Attributes as Record)?.teacherSub ?? identity.sub), + groupId, + updates.studentCount as number, + ); + } + return { statusCode: 200, body: JSON.stringify(mapGroupSummary(result.Attributes || {})) }; } +/** + * Set every active classroom (assignment) in a class to the class's new seat + * count. Enumerates via teacherSub-index + a groupId filter (no groupId GSI on + * the classrooms table), the same pattern topic cascades use. + * @param teacherSub - owning teacher (partition key of teacherSub-index) + * @param groupId - the class whose assignments to update + * @param studentCount - the new seat count to write + */ +async function propagateStudentCountToClassrooms( + teacherSub: string, groupId: string, studentCount: number, +): Promise { + const now = new Date().toISOString(); + const result = await docClient.send(new QueryCommand({ + TableName: CLASSROOMS_TABLE, + IndexName: 'teacherSub-index', + KeyConditionExpression: 'teacherSub = :ts', + FilterExpression: 'groupId = :gid AND #status = :active', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { ':ts': teacherSub, ':gid': groupId, ':active': 'active' }, + })); + for (const item of result.Items || []) { + await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId: item.classroomId }, + UpdateExpression: 'SET studentCount = :sc, updatedAt = :now', + ExpressionAttributeValues: { ':sc': studentCount, ':now': now }, + })); + } +} + /** * One-time (but idempotent) v1→v2 bulk migration for the calling teacher. * Runs the pure plan against the teacher's classrooms + groups, then executes @@ -3364,6 +3435,899 @@ async function handleEvaluateSubmissions( return { statusCode: 200, body: JSON.stringify({ mode: request.mode, results }) }; } +// --- みんなの課題 (shared assignment library, EPIC #1066) --- +// Teachers publish an assignment snapshot (pages + starter + supplement URL +// + minimal author profile) under CC BY 4.0; other teachers browse, filter, +// and import it into their own class. Shared items are permanent (no TTL, +// D7) and live in their own bucket so the classroom lifecycle never sweeps +// them. Design canon: spike #1067 (D1-D12). + +export const SHARED_SCHOOL_LEVELS = ['elementary', 'junior-high', 'high', 'other'] as const; + +/** Controlled subject vocabulary per school level (D5). 'other' is free text. */ +export const SHARED_SUBJECTS: Record = { + elementary: ['総合的な学習の時間', '算数', '理科', '図画工作', '特別活動・クラブ', 'その他'], + 'junior-high': ['技術・家庭(技術分野)', '数学', '理科', '総合的な学習の時間', 'その他'], + high: ['情報Ⅰ', '情報Ⅱ', 'その他'], + other: [], +}; + +const SHARED_MAX_GRADE: Record = { + elementary: 6, + 'junior-high': 3, + high: 3, + other: 6, +}; + +interface SharedAttributes { + schoolLevel: string; + grades: number[]; + subject: string; + tags: string[]; + lessonCount: number | null; +} + +/** + * Validate the school attributes of a share/update request (D5). + * @param body - request body + * @returns normalized attributes + */ +export function validateSharedAttributes(body: Record): SharedAttributes { + const schoolLevel = body.schoolLevel; + if (typeof schoolLevel !== 'string' || !(SHARED_SCHOOL_LEVELS as readonly string[]).includes(schoolLevel)) { + throw new ValidationError(`schoolLevel must be one of: ${SHARED_SCHOOL_LEVELS.join(', ')}`); + } + + const subject = body.subject; + if (typeof subject !== 'string' || subject.trim().length === 0) { + throw new ValidationError('subject is required'); + } + const vocabulary = SHARED_SUBJECTS[schoolLevel]; + if (vocabulary.length > 0 && !vocabulary.includes(subject)) { + throw new ValidationError(`subject must be one of: ${vocabulary.join(' / ')}`); + } + if (vocabulary.length === 0 && subject.trim().length > MAX_SHARED_TAG_LENGTH) { + throw new ValidationError(`subject must be ${MAX_SHARED_TAG_LENGTH} characters or less`); + } + + const maxGrade = SHARED_MAX_GRADE[schoolLevel]; + let grades: number[] = []; + if (body.grades !== undefined) { + if (!Array.isArray(body.grades)) { + throw new ValidationError('grades must be an array'); + } + grades = [...new Set(body.grades)].map(g => { + if (typeof g !== 'number' || !Number.isInteger(g) || g < 1 || g > maxGrade) { + throw new ValidationError(`grades must be integers between 1 and ${maxGrade}`); + } + return g; + }).sort((a, b) => a - b); + } + + let tags: string[] = []; + if (body.tags !== undefined) { + if (!Array.isArray(body.tags)) { + throw new ValidationError('tags must be an array'); + } + tags = [...new Set(body.tags.map(t => { + if (typeof t !== 'string' || t.trim().length === 0 || t.trim().length > MAX_SHARED_TAG_LENGTH) { + throw new ValidationError(`each tag must be 1-${MAX_SHARED_TAG_LENGTH} characters`); + } + return t.trim(); + }))]; + if (tags.length > MAX_SHARED_TAGS) { + throw new ValidationError(`at most ${MAX_SHARED_TAGS} tags are allowed`); + } + } + + let lessonCount: number | null = null; + if (body.lessonCount !== undefined && body.lessonCount !== null) { + if (typeof body.lessonCount !== 'number' || !Number.isInteger(body.lessonCount) || + body.lessonCount < 1 || body.lessonCount > 20) { + throw new ValidationError('lessonCount must be an integer between 1 and 20'); + } + lessonCount = body.lessonCount; + } + + return { schoolLevel, grades, subject: subject.trim(), tags, lessonCount }; +} + +/** + * Validate the supplement URL (D4): https only, parseable, bounded length. + * @param value - raw URL from the request + * @returns normalized URL or null when absent + */ +export function validateSupplementUrl(value: unknown): string | null { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string' || value.length > MAX_SUPPLEMENT_URL_LENGTH) { + throw new ValidationError(`supplementUrl must be a string of at most ${MAX_SUPPLEMENT_URL_LENGTH} characters`); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new ValidationError('supplementUrl must be a valid URL'); + } + if (parsed.protocol !== 'https:') { + throw new ValidationError('supplementUrl must use https'); + } + return value; +} + +/** + * Validate the minimal public author profile (D6). + * @param body - request body + * @returns display name + optional affiliation + */ +export function validateAuthorProfile(body: Record): { + authorName: string; + authorAffiliation: string | null; +} { + const name = body.authorName; + if (typeof name !== 'string' || name.trim().length === 0 || name.trim().length > MAX_AUTHOR_NAME_LENGTH) { + throw new ValidationError(`authorName is required (at most ${MAX_AUTHOR_NAME_LENGTH} characters)`); + } + let affiliation: string | null = null; + if (body.authorAffiliation !== undefined && body.authorAffiliation !== null && body.authorAffiliation !== '') { + if (typeof body.authorAffiliation !== 'string' || + body.authorAffiliation.trim().length > MAX_AUTHOR_AFFILIATION_LENGTH) { + throw new ValidationError(`authorAffiliation must be at most ${MAX_AUTHOR_AFFILIATION_LENGTH} characters`); + } + affiliation = body.authorAffiliation.trim(); + } + return { authorName: name.trim(), authorAffiliation: affiliation }; +} + +/** + * Build the shared snapshot of an assignment: pages/starter keys rewritten + * into the shared bucket's `shared/{sharedId}/` prefix plus the copy plan. + * Mirror of buildDuplicatedAssignment, kept pure for tests. + * @param assignment - the classroom's assignment content + * @param sharedId - target shared item id + * @returns rewritten pages/starterKey and the copy list + */ +export function buildSharedSnapshot( + assignment: AssignmentContent | undefined, + sharedId: string, +): { pages: AssignmentPage[]; starterKey?: string; copies: { from: string; to: string }[] } { + const copies: { from: string; to: string }[] = []; + const rewriteKey = (key: string): string => { + const to = `shared/${sharedId}/${key.split('/').pop()}`; + copies.push({ from: key, to }); + return to; + }; + const pages = (assignment?.pages || []).map(page => + page.imageKey ? { text: page.text, imageKey: rewriteKey(page.imageKey) } : { text: page.text }, + ); + const starterKey = assignment?.starterKey ? rewriteKey(assignment.starterKey) : undefined; + return { pages, starterKey, copies }; +} + +/** Public list/detail projection — never exposes authorSub / internal keys. */ +/** + * Public-facing shape of a shared assignment. + * @param item - the DynamoDB shared item + * @param opts - includePasscode echoes the 合言葉 (author-only; never in the + * public catalog). visibility defaults to 'public' for pre-#1109 items. + * @returns the summary object sent to clients + */ +function mapSharedSummary(item: Record, opts: { includePasscode?: boolean } = {}) { + return { + sharedId: item.sharedId, + title: item.title, + summary: item.summary || null, + schoolLevel: item.schoolLevel, + grades: item.grades || [], + subject: item.subject, + tags: item.tags || [], + lessonCount: item.lessonCount || null, + supplementUrl: item.supplementUrl || null, + authorName: item.authorName, + authorAffiliation: item.authorAffiliation || null, + pageCount: Array.isArray((item.content as AssignmentContent | undefined)?.pages) + ? (item.content as AssignmentContent).pages!.length + : 0, + hasStarter: !!(item.content as AssignmentContent | undefined)?.starterKey, + reuseCount: (item.reuseCount as number) || 0, + // 公開範囲: 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。 + visibility: (item.visibility as string) || 'public', + status: item.status, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + ...(opts.includePasscode && item.passcode ? { passcode: item.passcode } : {}), + }; +} + +/** + * Generate a shared-assignment 合言葉 (passcode) unique across limited items. + * Reuses the join-code alphabet/length and the passcode-index GSI for the + * uniqueness check (same retry policy as classroom join codes). + * @returns a unique passcode, or '' if none was found after retries + */ +async function generateUniqueSharedPasscode(): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = generateJoinCode(); + const existing = await docClient.send(new QueryCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + IndexName: 'passcode-index', + KeyConditionExpression: 'passcode = :pc', + ExpressionAttributeValues: { ':pc': candidate }, + Limit: 1, + })); + if (!existing.Items || existing.Items.length === 0) { + return candidate; + } + } + return ''; +} + +/** + * Look up a limited-published shared item by its 合言葉 (passcode). + * @param passcode - the 合言葉 + * @returns the shared item, or null when no limited item matches + */ +async function getSharedItemByPasscode(passcode: string): Promise | null> { + const result = await docClient.send(new QueryCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + IndexName: 'passcode-index', + KeyConditionExpression: 'passcode = :pc', + ExpressionAttributeValues: { ':pc': passcode }, + Limit: 1, + })); + const item = (result.Items && result.Items[0]) as Record | undefined; + if (!item || item.status !== 'published' || item.visibility !== 'limited') return null; + return item; +} + +/** + * Durable daily quota shared by the share/report endpoints (D12): an atomic + * counter per teacher per UTC day, stored in the Classrooms table's reserved + * key space (same pattern as eval-quota; TTL cleans it after two days). + */ +async function checkSharedDailyLimit(kind: string, teacherSub: string, limit: number): Promise { + const day = new Date().toISOString().slice(0, 10); + const result = await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId: `${kind}-quota#${teacherSub}#${day}` }, + UpdateExpression: 'ADD #c :one SET #ttl = if_not_exists(#ttl, :ttl)', + ExpressionAttributeNames: { '#c': 'count', '#ttl': 'ttl' }, + ExpressionAttributeValues: { + ':one': 1, + ':ttl': Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60, + }, + ReturnValues: 'UPDATED_NEW', + })); + const count = (result.Attributes?.count as number) || 0; + if (count > limit) { + throw new ValidationError(`Daily limit reached (${limit}/day). Please continue tomorrow.`); + } +} + +async function getSharedItem(sharedId: string): Promise | null> { + const result = await docClient.send(new GetCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + })); + return (result.Item as Record) || null; +} + +async function handleShareAssignment( + identity: TeacherIdentity, body: Record, +): Promise { + // 公開範囲(#1109): 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。 + const visibility = body.visibility === 'limited' ? 'limited' : 'public'; + + const title = body.title; + if (typeof title !== 'string' || title.trim().length === 0 || title.trim().length > MAX_SHARED_TITLE_LENGTH) { + throw new ValidationError(`title is required (at most ${MAX_SHARED_TITLE_LENGTH} characters)`); + } + let summary: string | null = null; + if (body.summary !== undefined && body.summary !== null && body.summary !== '') { + if (typeof body.summary !== 'string' || body.summary.trim().length > MAX_SHARED_SUMMARY_LENGTH) { + throw new ValidationError(`summary must be at most ${MAX_SHARED_SUMMARY_LENGTH} characters`); + } + summary = body.summary.trim(); + } + + // 全体公開は CC BY 同意・属性・著者名が必須。限定公開(合言葉)は内輪向けで + // それらは任意(後で全体公開へ広げるときに必須化する。障壁=完璧さの圧を下げる)。 + let attributes: Record = {}; + let profile: Record = {}; + let supplementUrl: string | null = null; + if (visibility === 'public') { + if (body.licenseConsent !== true) { + throw new ValidationError('licenseConsent (CC BY 4.0) is required'); + } + attributes = validateSharedAttributes(body) as unknown as Record; + supplementUrl = validateSupplementUrl(body.supplementUrl); + profile = validateAuthorProfile(body) as unknown as Record; + } else { + supplementUrl = validateSupplementUrl(body.supplementUrl); + if (body.authorName !== undefined && body.authorName !== null && body.authorName !== '') { + profile = validateAuthorProfile(body) as unknown as Record; + } + } + + // Source classroom: must be the teacher's own, active, with content. + const classroomId = body.classroomId; + if (typeof classroomId !== 'string' || !classroomId) { + throw new ValidationError('classroomId is required'); + } + const classroomResult = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + if (!classroomResult.Item || classroomResult.Item.status !== 'active') { + throw new NotFoundError('Classroom not found'); + } + if (!(await canManageViaGroup(classroomResult.Item, identity))) { + throw new AuthError('Not authorized to share this classroom'); + } + const assignment = classroomResult.Item.assignment as AssignmentContent | undefined; + if (!assignment || ((!assignment.pages || assignment.pages.length === 0) && !assignment.starterKey)) { + throw new ValidationError('This assignment has no content (pages or starter project) to share'); + } + + // Starter size cap (D11) — checked before any copy. + if (assignment.starterKey) { + const head = await s3Client.send(new HeadObjectCommand({ + Bucket: SUBMISSIONS_BUCKET, + Key: assignment.starterKey, + })); + if ((head.ContentLength || 0) > SHARED_STARTER_MAX_BYTES) { + throw new ValidationError( + `Starter project exceeds the ${Math.floor(SHARED_STARTER_MAX_BYTES / (1024 * 1024))}MB limit`, + ); + } + } + + await checkSharedDailyLimit('share', identity.sub, SHARE_DAILY_LIMIT); + + const sharedId = crypto.randomUUID(); + const { pages, starterKey, copies } = buildSharedSnapshot(assignment, sharedId); + + // Copy content into the shared bucket first so the record never + // references missing objects (same ordering as duplicate). + for (const { from, to } of copies) { + await s3Client.send(new CopyObjectCommand({ + Bucket: SHARED_BUCKET, + CopySource: `${SUBMISSIONS_BUCKET}/${encodeURIComponent(from)}`, + Key: to, + })); + } + + const now = new Date().toISOString(); + // 限定公開は合言葉(参加コード同型)を発行。全体公開は発行しない。 + let passcode = ''; + if (visibility === 'limited') { + passcode = await generateUniqueSharedPasscode(); + if (!passcode) { + return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate a unique passcode' }) }; + } + } + const item: Record = { + sharedId, + title: title.trim(), + summary, + content: { pages, starterKey }, + supplementUrl, + ...attributes, + ...profile, + authorSub: identity.sub, + visibility, + ...(passcode ? { passcode } : {}), + status: 'published', + reuseCount: 0, + createdAt: now, + updatedAt: now, + }; + await docClient.send(new PutCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Item: item, + })); + + return { statusCode: 201, body: JSON.stringify(mapSharedSummary(item, { includePasscode: true })) }; +} + +async function handleListSharedAssignments( + identity: TeacherIdentity, query: Record, +): Promise { + const mine = query.mine === '1' || query.mine === 'true'; + + // Optional attribute filters (D8): applied server-side as a + // FilterExpression on top of the newest-first GSI query. + const filterParts: string[] = []; + const names: Record = {}; + const values: Record = {}; + if (query.schoolLevel) { + filterParts.push('#sl = :sl'); + names['#sl'] = 'schoolLevel'; + values[':sl'] = query.schoolLevel; + } + if (query.subject) { + filterParts.push('#sub = :sub'); + names['#sub'] = 'subject'; + values[':sub'] = query.subject; + } + if (query.grade) { + const grade = parseInt(query.grade, 10); + if (!Number.isNaN(grade)) { + filterParts.push('contains(#gr, :gr)'); + names['#gr'] = 'grades'; + values[':gr'] = grade; + } + } + if (query.tag) { + filterParts.push('contains(#tg, :tg)'); + names['#tg'] = 'tags'; + values[':tg'] = query.tag; + } + // 公開カタログ(mine 以外)は限定公開(合言葉)を除外する。#1109 より前の + // 項目は visibility 属性を持たないので「公開」とみなす。mine は両方見せる。 + if (!mine) { + filterParts.push('(attribute_not_exists(#vis) OR #vis = :pub)'); + names['#vis'] = 'visibility'; + values[':pub'] = 'public'; + } + + let exclusiveStartKey: Record | undefined; + if (query.cursor) { + try { + exclusiveStartKey = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')); + } catch { + throw new ValidationError('Invalid cursor'); + } + } + + // DynamoDB rejects an EMPTY ExpressionAttributeNames map, so the key must + // be omitted when there is nothing to alias (mine=1 with no filters). + const expressionNames = { + ...(mine ? {} : { '#status': 'status' }), + ...names, + }; + const result = await docClient.send(new QueryCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + IndexName: mine ? 'authorSub-createdAt-index' : 'status-createdAt-index', + KeyConditionExpression: mine ? 'authorSub = :pk' : '#status = :pk', + ...(Object.keys(expressionNames).length > 0 ? { ExpressionAttributeNames: expressionNames } : {}), + ExpressionAttributeValues: { + ':pk': mine ? identity.sub : 'published', + ...values, + }, + FilterExpression: filterParts.length > 0 ? filterParts.join(' AND ') : undefined, + ScanIndexForward: false, + Limit: SHARED_LIST_PAGE_SIZE, + ExclusiveStartKey: exclusiveStartKey, + })); + + const cursor = result.LastEvaluatedKey + ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64url') + : null; + return { + statusCode: 200, + body: JSON.stringify({ + // mine の一覧は自分の項目なので合言葉を含める(限定公開の共有に使う)。 + items: (result.Items || []).map((it) => mapSharedSummary(it, { includePasscode: mine })), + cursor, + }), + }; +} + +async function handleGetSharedAssignment( + identity: TeacherIdentity, sharedId: string, +): Promise { + const item = await getSharedItem(sharedId); + // Existence-hiding 404. The author may view their own unlisted item. + // 限定公開(合言葉)は sharedId を知っていても著者以外には見せない + // (非著者は合言葉ルックアップ経由でのみ取り込む)。 + const isMine = !!item && item.authorSub === identity.sub; + if (!item || (!isMine && (item.status !== 'published' || item.visibility === 'limited'))) { + throw new NotFoundError('Shared assignment not found'); + } + + const content = (item.content as AssignmentContent | undefined) || {}; + const pages = await Promise.all((content.pages || []).map(async page => ({ + text: page.text, + imageUrl: page.imageKey + ? await getSignedUrl( + s3Client, + new GetObjectCommand({ Bucket: SHARED_BUCKET, Key: page.imageKey }), + { expiresIn: PRESIGNED_URL_DOWNLOAD_EXPIRY }, + ) + : null, + }))); + const starterUrl = content.starterKey + ? await getSignedUrl( + s3Client, + new GetObjectCommand({ Bucket: SHARED_BUCKET, Key: content.starterKey }), + { expiresIn: PRESIGNED_URL_DOWNLOAD_EXPIRY }, + ) + : null; + + return { + statusCode: 200, + body: JSON.stringify({ + ...mapSharedSummary(item, { includePasscode: isMine }), + pages, + starterUrl, + isMine, + }), + }; +} + +async function handleImportSharedAssignment( + identity: TeacherIdentity, sharedId: string, body: Record, +): Promise { + const item = await getSharedItem(sharedId); + // 全体公開のみ sharedId で取り込める。限定公開(合言葉)は + // handleImportByPasscode 経由でのみ取り込む(sharedId は非著者に露出しない)。 + if (!item || item.status !== 'published' || item.visibility === 'limited') { + throw new NotFoundError('Shared assignment not found'); + } + return importSharedItem(identity, item, body); +} + +/** + * Import a resolved shared item into one of the caller's classes as a new + * assignment. Copies content into the classroom bucket, mints a fresh join + * code, and bumps reuseCount. Shared by sharedId-import and passcode-import. + * @param identity - the importing teacher + * @param item - the resolved shared assignment item + * @param body - request body (groupId required, assignmentName optional) + * @returns the created classroom summary + */ +async function importSharedItem( + identity: TeacherIdentity, item: Record, body: Record, +): Promise { + const sharedId = item.sharedId as string; + const groupId = body.groupId; + if (typeof groupId !== 'string' || !groupId) { + throw new ValidationError('groupId is required'); + } + const group = await getOwnedGroup(identity, groupId); + + const assignmentName = body.assignmentName !== undefined + ? validateClassName(body.assignmentName) + : (item.title as string); + + // Fresh unique join code (same retry policy as creation/duplication). + let joinCode = ''; + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = generateJoinCode(); + const existing = await docClient.send(new QueryCommand({ + TableName: CLASSROOMS_TABLE, + IndexName: 'joinCode-index', + KeyConditionExpression: 'joinCode = :jc', + ExpressionAttributeValues: { ':jc': candidate }, + Limit: 1, + })); + if (!existing.Items || existing.Items.length === 0) { + joinCode = candidate; + break; + } + } + if (!joinCode) { + return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate unique join code' }) }; + } + + const newClassroomId = crypto.randomUUID(); + const content = (item.content as AssignmentContent | undefined) || {}; + const { assignment, copies } = buildDuplicatedAssignment(content, `shared/${sharedId}`, newClassroomId); + + // Copy shared objects into the classroom bucket first (never reference + // missing objects). Source is the shared bucket. + for (const { from, to } of copies) { + await s3Client.send(new CopyObjectCommand({ + Bucket: SUBMISSIONS_BUCKET, + CopySource: `${SHARED_BUCKET}/${encodeURIComponent(from)}`, + Key: to, + })); + } + + const now = new Date().toISOString(); + const ttl = Math.floor(Date.now() / 1000) + CLASSROOM_TTL_SECONDS; + const studentCount = typeof group.studentCount === 'number' ? group.studentCount : 40; + await docClient.send(new PutCommand({ + TableName: CLASSROOMS_TABLE, + Item: { + classroomId: newClassroomId, + teacherSub: identity.sub, + className: group.name, + assignmentName, + joinCode, + studentCount, + groupId, + sortDate: now, + assignment, + status: 'active', + createdAt: now, + updatedAt: now, + ttl, + }, + })); + + // Popularity signal (D8 future sort) — best effort, never blocks import. + try { + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'ADD reuseCount :one', + ExpressionAttributeValues: { ':one': 1 }, + })); + } catch (err) { + console.error('Failed to bump reuseCount:', err); + } + + return { + statusCode: 201, + body: JSON.stringify({ + classroomId: newClassroomId, + className: group.name, + assignmentName, + joinCode, + studentCount, + groupId, + sortDate: now, + hasAssignment: !!assignment, + status: 'active', + createdAt: now, + expiresAt: new Date(ttl * 1000).toISOString(), + }), + }; +} + +/** + * Look up a limited-published shared assignment by 合言葉 for a preview/confirm + * step. Does not expose the sharedId (import re-supplies the passcode), so the + * passcode remains the only access token to a limited item. + * @param identity - the requesting teacher + * @param body - request body ({ passcode }) + * @returns the shared summary (no sharedId, no passcode echoed) + */ +async function handleLookupSharedByPasscode( + identity: TeacherIdentity, body: Record, +): Promise { + const passcode = validateJoinCode(body.passcode); + const item = await getSharedItemByPasscode(passcode); + if (!item) { + throw new NotFoundError('Shared assignment not found'); + } + const { sharedId: _omit, ...summary } = mapSharedSummary(item); + return { statusCode: 200, body: JSON.stringify(summary) }; +} + +/** + * Import a limited-published shared assignment by 合言葉 (内輪取り込み). + * The passcode is the authorization; the sharedId is never surfaced. + * @param identity - the importing teacher + * @param body - request body ({ passcode, groupId, assignmentName? }) + * @returns the created classroom summary + */ +async function handleImportSharedByPasscode( + identity: TeacherIdentity, body: Record, +): Promise { + const passcode = validateJoinCode(body.passcode); + const item = await getSharedItemByPasscode(passcode); + if (!item) { + throw new NotFoundError('Shared assignment not found'); + } + return importSharedItem(identity, item, body); +} + +async function handleUpdateSharedAssignment( + identity: TeacherIdentity, sharedId: string, body: Record, +): Promise { + const item = await getSharedItem(sharedId); + if (!item || item.authorSub !== identity.sub) { + throw new NotFoundError('Shared assignment not found'); + } + + const updates: Record = { updatedAt: new Date().toISOString() }; + if (body.title !== undefined) { + if (typeof body.title !== 'string' || body.title.trim().length === 0 || + body.title.trim().length > MAX_SHARED_TITLE_LENGTH) { + throw new ValidationError(`title must be 1-${MAX_SHARED_TITLE_LENGTH} characters`); + } + updates.title = body.title.trim(); + } + if (body.summary !== undefined) { + if (body.summary === null || body.summary === '') { + updates.summary = null; + } else if (typeof body.summary === 'string' && body.summary.trim().length <= MAX_SHARED_SUMMARY_LENGTH) { + updates.summary = body.summary.trim(); + } else { + throw new ValidationError(`summary must be at most ${MAX_SHARED_SUMMARY_LENGTH} characters`); + } + } + if (body.schoolLevel !== undefined || body.subject !== undefined || + body.grades !== undefined || body.tags !== undefined || body.lessonCount !== undefined) { + // Attributes are validated as a set (subject depends on schoolLevel). + const merged = { + 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, + }; + Object.assign(updates, validateSharedAttributes(merged as Record)); + } + if (body.supplementUrl !== undefined) { + updates.supplementUrl = validateSupplementUrl(body.supplementUrl); + } + if (body.authorName !== undefined || body.authorAffiliation !== undefined) { + const profile = validateAuthorProfile({ + authorName: body.authorName !== undefined ? body.authorName : item.authorName, + authorAffiliation: body.authorAffiliation !== undefined ? body.authorAffiliation : item.authorAffiliation, + }); + Object.assign(updates, profile); + } + if (body.status !== undefined) { + if (body.status !== 'published' && body.status !== 'unlisted') { + throw new ValidationError('Status must be "published" or "unlisted"'); + } + updates.status = body.status; + } + + // 公開範囲の変更(#1109)。限定公開 → 全体公開に広げるときは、全体公開に + // 必要な属性・著者名・CC BY 同意(body か既存値)が揃っていることを要求する。 + if (body.visibility !== undefined) { + if (body.visibility !== 'public' && body.visibility !== 'limited') { + throw new ValidationError('visibility must be "public" or "limited"'); + } + const currentVisibility = (item.visibility as string) || 'public'; + if (body.visibility === 'public' && currentVisibility !== 'public') { + if (body.licenseConsent !== true) { + throw new ValidationError('licenseConsent (CC BY 4.0) is required to make it public'); + } + 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, + } as Record)); + Object.assign(updates, validateAuthorProfile({ + authorName: body.authorName ?? item.authorName, + authorAffiliation: body.authorAffiliation ?? item.authorAffiliation, + })); + updates.visibility = 'public'; + } else if (body.visibility === 'limited' && currentVisibility !== 'limited') { + updates.visibility = 'limited'; + if (!item.passcode) { + const pc = await generateUniqueSharedPasscode(); + if (!pc) { + return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate a unique passcode' }) }; + } + updates.passcode = pc; + } + } + } + + // Optional content re-snapshot (D10, overwrite semantics): pull the + // current pages/starter from one of the teacher's own classrooms. + if (body.classroomId !== undefined) { + if (typeof body.classroomId !== 'string' || !body.classroomId) { + throw new ValidationError('classroomId must be a string'); + } + const classroomResult = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId: body.classroomId }, + })); + if (!classroomResult.Item || classroomResult.Item.status !== 'active') { + throw new NotFoundError('Classroom not found'); + } + if (!(await canManageViaGroup(classroomResult.Item, identity))) { + throw new AuthError('Not authorized to share this classroom'); + } + const assignment = classroomResult.Item.assignment as AssignmentContent | undefined; + if (!assignment || ((!assignment.pages || assignment.pages.length === 0) && !assignment.starterKey)) { + throw new ValidationError('This assignment has no content (pages or starter project) to share'); + } + if (assignment.starterKey) { + const head = await s3Client.send(new HeadObjectCommand({ + Bucket: SUBMISSIONS_BUCKET, + Key: assignment.starterKey, + })); + if ((head.ContentLength || 0) > SHARED_STARTER_MAX_BYTES) { + throw new ValidationError( + `Starter project exceeds the ${Math.floor(SHARED_STARTER_MAX_BYTES / (1024 * 1024))}MB limit`, + ); + } + } + const { pages, starterKey, copies } = buildSharedSnapshot(assignment, sharedId); + for (const { from, to } of copies) { + await s3Client.send(new CopyObjectCommand({ + Bucket: SHARED_BUCKET, + CopySource: `${SUBMISSIONS_BUCKET}/${encodeURIComponent(from)}`, + Key: to, + })); + } + // Best-effort cleanup of orphaned old objects (keys are content-unique). + const oldContent = (item.content as AssignmentContent | undefined) || {}; + const newKeys = new Set([...pages.map(p => p.imageKey), starterKey].filter(Boolean)); + const oldKeys = [ + ...(oldContent.pages || []).map(p => p.imageKey), + oldContent.starterKey, + ].filter((key): key is string => !!key && !newKeys.has(key)); + await Promise.allSettled(oldKeys.map(key => + s3Client.send(new DeleteObjectCommand({ Bucket: SHARED_BUCKET, Key: key })), + )); + updates.content = { pages, starterKey }; + } + + const expressionParts: string[] = []; + const expressionValues: Record = {}; + const expressionNames: Record = {}; + let i = 0; + for (const [key, value] of Object.entries(updates)) { + expressionNames[`#attr${i}`] = key; + expressionValues[`:val${i}`] = value; + expressionParts.push(`#attr${i} = :val${i}`); + i++; + } + const result = await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: `SET ${expressionParts.join(', ')}`, + ExpressionAttributeNames: expressionNames, + ExpressionAttributeValues: expressionValues, + ReturnValues: 'ALL_NEW', + })); + + return { + statusCode: 200, + body: JSON.stringify(mapSharedSummary(result.Attributes || {}, { includePasscode: true })), + }; +} + +async function handleUnlistSharedAssignment( + identity: TeacherIdentity, sharedId: string, +): Promise { + const item = await getSharedItem(sharedId); + if (!item || item.authorSub !== identity.sub) { + throw new NotFoundError('Shared assignment not found'); + } + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'SET #status = :status, updatedAt = :now', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { ':status': 'unlisted', ':now': new Date().toISOString() }, + })); + return { statusCode: 204, body: '' }; +} + +async function handleReportSharedAssignment( + identity: TeacherIdentity, sharedId: string, body: Record, +): Promise { + const reason = body.reason; + if (typeof reason !== 'string' || reason.trim().length === 0 || + reason.trim().length > MAX_SHARED_REPORT_REASON_LENGTH) { + throw new ValidationError(`reason is required (at most ${MAX_SHARED_REPORT_REASON_LENGTH} characters)`); + } + const item = await getSharedItem(sharedId); + if (!item || item.status !== 'published') { + throw new NotFoundError('Shared assignment not found'); + } + + await checkSharedDailyLimit('report', identity.sub, REPORT_DAILY_LIMIT); + + await docClient.send(new PutCommand({ + TableName: SHARED_REPORTS_TABLE, + Item: { + sharedId, + reportId: crypto.randomUUID(), + reason: reason.trim(), + // Internal only (abuse tracing) — never returned by any endpoint. + reporterSub: identity.sub, + createdAt: new Date().toISOString(), + ttl: Math.floor(Date.now() / 1000) + SHARED_REPORT_TTL_SECONDS, + }, + })); + + return { statusCode: 201, body: JSON.stringify({}) }; +} + // --- Main handler --- export const handler = async (event: APIGatewayProxyEventV2): Promise => { @@ -3415,6 +4379,57 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise [sharedId] [--apply]'); + } + const args: AdminArgs = { command, sharedId: null, apply: false }; + for (const token of rest) { + if (token === '--apply') { + args.apply = true; + } else if (!token.startsWith('-') && !args.sharedId) { + args.sharedId = token; + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + if (COMMANDS_NEEDING_ID.includes(command) && !args.sharedId) { + throw new Error(`${command} requires a sharedId`); + } + return args; +} + +export interface ReportRecord { + sharedId: string; + reason: string; + createdAt: string; +} + +/** + * Group report rows by sharedId, newest first inside each group. + * @param reports - raw report items + * @returns sharedId → reports (sorted), insertion ordered by report count desc + */ +export function groupReports(reports: ReportRecord[]): Map { + const byId = new Map(); + for (const report of reports) { + const list = byId.get(report.sharedId) || []; + list.push(report); + byId.set(report.sharedId, list); + } + for (const list of byId.values()) { + list.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')); + } + return new Map([...byId.entries()].sort((a, b) => b[1].length - a[1].length)); +} + +/** + * Render the report queue: one block per reported item with its title, + * status and the reasons (reporterSub is never shown). + * @param grouped - output of groupReports + * @param itemsById - shared items looked up for the reported ids + * @returns printable lines + */ +export function renderReportQueue( + grouped: Map, + itemsById: Map>, +): string[] { + if (grouped.size === 0) return ['通報はありません。']; + const lines: string[] = []; + for (const [sharedId, reports] of grouped.entries()) { + const item = itemsById.get(sharedId); + const title = item ? String(item.title) : '(削除済み/不明)'; + const status = item ? String(item.status) : '-'; + lines.push(`■ ${title} (${sharedId}) status=${status} 通報 ${reports.length} 件`); + for (const report of reports) { + lines.push(` - [${report.createdAt}] ${report.reason}`); + } + } + return lines; +} + +/** + * Render one shared item for the `show` command (public fields only). + * @param item - the shared assignment item + * @returns printable lines + */ +export function renderSharedItem(item: Record): string[] { + const content = (item.content || {}) as { pages?: { text: string }[]; starterKey?: string }; + const lines = [ + `sharedId: ${item.sharedId}`, + `title: ${item.title}`, + `status: ${item.status}`, + `author: ${item.authorName}${item.authorAffiliation ? `(${item.authorAffiliation})` : ''}`, + `attributes: ${item.schoolLevel} / ${item.subject} / 学年 ${(item.grades as number[] | undefined)?.join('・') || '-'}`, + `tags: ${(item.tags as string[] | undefined)?.join(', ') || '-'}`, + `supplement: ${item.supplementUrl || '-'}`, + `reuse: ${item.reuseCount || 0} 回`, + `created: ${item.createdAt} / updated: ${item.updatedAt}`, + `starter: ${content.starterKey ? 'あり' : 'なし'}`, + 'pages:', + ]; + for (const [index, page] of (content.pages || []).entries()) { + lines.push(` ${index + 1}. ${page.text.slice(0, 120).replace(/\n/g, ' / ')}`); + } + return lines; +} diff --git a/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts b/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts new file mode 100644 index 0000000000..7265213529 --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts @@ -0,0 +1,120 @@ +/** + * Class (group) seat-count propagation to existing assignments. + * + * Changing a class's studentCount must flow down to its active assignments + * (classrooms) — growing adds seats, shrinking drops them (the teacher UI + * warns before shrinking). Regression for "既存の課題の人数が増えない". + */ + +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/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { ...actual, S3Client: jest.fn(() => ({ send: jest.fn() })) }; +}); +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) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'http://localhost:8601' }, + pathParameters, + body: JSON.stringify(body), +}); + +describe('class seat-count propagation (PATCH /classroom-groups/{id})', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.CORS_ALLOWED_ORIGINS = 'http://localhost:8601'; + mockSend.mockReset(); + handler = require('../handler').handler; + }); + + const drive = async (newCount: number, classroomIds: string[]) => { + const classroomUpdates: { classroomId: string; studentCount: number }[] = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = String(command.input?.TableName || ''); + if (name === 'GetCommand') { + // getOwnedGroup → the owning class + return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', year: 2026, studentCount: 30, schemaVersion: 2 } }; + } + if (name === 'UpdateCommand' && table.includes('Group')) { + return { Attributes: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', year: 2026, studentCount: newCount, schemaVersion: 2 } }; + } + if (name === 'QueryCommand') { + // propagation enumerates the class's active classrooms + return { Items: classroomIds.map(classroomId => ({ classroomId })) }; + } + if (name === 'UpdateCommand') { + const values = command.input?.ExpressionAttributeValues as Record; + classroomUpdates.push({ + classroomId: String((command.input?.Key as Record).classroomId), + studentCount: values[':sc'] as number, + }); + return {}; + } + return {}; + }); + + const res = await handler(makeEvent('PATCH', '/classroom-groups/g1', { groupId: 'g1' }, { studentCount: newCount })); + return { res, classroomUpdates }; + }; + + test('increasing the class count raises every active assignment (no warning needed server-side)', async () => { + const { res, classroomUpdates } = await drive(35, ['c1', 'c2']); + expect(res.statusCode).toBe(200); + expect(classroomUpdates).toEqual([ + { classroomId: 'c1', studentCount: 35 }, + { classroomId: 'c2', studentCount: 35 }, + ]); + }); + + test('decreasing the class count lowers every active assignment too', async () => { + const { res, classroomUpdates } = await drive(20, ['c1']); + expect(res.statusCode).toBe(200); + expect(classroomUpdates).toEqual([{ classroomId: 'c1', studentCount: 20 }]); + }); + + test('the propagation query filters to the class + active status', async () => { + let queryInput: Record | undefined; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + if (name === 'GetCommand') { + return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', studentCount: 30, schemaVersion: 2 } }; + } + if (name === 'UpdateCommand' && String(command.input?.TableName).includes('Group')) { + return { Attributes: { groupId: 'g1', teacherSub: 'dev-test-teacher', studentCount: 40 } }; + } + if (name === 'QueryCommand') { + queryInput = command.input; + return { Items: [] }; + } + return {}; + }); + + await handler(makeEvent('PATCH', '/classroom-groups/g1', { groupId: 'g1' }, { studentCount: 40 })); + expect(queryInput?.IndexName).toBe('teacherSub-index'); + expect(queryInput?.FilterExpression).toContain('groupId = :gid'); + const values = queryInput?.ExpressionAttributeValues as Record; + expect(values[':gid']).toBe('g1'); + expect(values[':active']).toBe('active'); + }); +}); diff --git a/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts b/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts new file mode 100644 index 0000000000..eb49c84b10 --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts @@ -0,0 +1,651 @@ +/** + * みんなの課題 (shared assignment library) tests — issue #1068 / EPIC #1066. + * + * Exercises the full request path (router → auth → handlers) with the + * DynamoDB document client and S3 mocked, plus the pure validators. + * Design canon: spike #1067 (D1-D12). + */ + +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'; + +interface MakeEventOptions { + body?: unknown; + token?: string; + query?: Record; +} + +const makeEvent = ( + method: string, + path: string, + pathParameters: Record, + { body, token, query }: MakeEventOptions = {}, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'http://localhost:8601', + }, + pathParameters, + queryStringParameters: query, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const ownClassroom = { + classroomId: 'c1', + status: 'active', + teacherSub: 'dev-test-teacher', + className: '技術', + assignmentName: 'ねこあつめ', + studentCount: 30, + assignment: { + pages: [ + { text: 'ページ1', imageKey: 'c1/assignment/image-abc.png' }, + { text: 'ページ2' }, + ], + starterKey: 'c1/assignment/starter-xyz.sb3', + }, +}; + +const shareBody = { + classroomId: 'c1', + title: 'ねこあつめ入門', + summary: 'はじめてのゲームづくり', + schoolLevel: 'junior-high', + grades: [1, 2], + subject: '技術・家庭(技術分野)', + tags: ['甲子園', '入門'], + lessonCount: 3, + supplementUrl: 'https://docs.google.com/document/d/abc/view', + authorName: 'すもう るびお', + authorAffiliation: '島根県 公立中学校', + licenseConsent: true, +}; + +const publishedItem = { + sharedId: 's1', + title: 'ねこあつめ入門', + summary: 'はじめてのゲームづくり', + content: { + pages: [{ text: 'ページ1', imageKey: 'shared/s1/image-abc.png' }, { text: 'ページ2' }], + starterKey: 'shared/s1/starter-xyz.sb3', + }, + supplementUrl: 'https://docs.google.com/document/d/abc/view', + schoolLevel: 'junior-high', + grades: [1, 2], + subject: '技術・家庭(技術分野)', + tags: ['甲子園'], + lessonCount: 3, + authorName: 'すもう るびお', + authorAffiliation: '島根県 公立中学校', + authorSub: 'someone-else', + status: 'published', + reuseCount: 4, + createdAt: '2026-07-18T00:00:00.000Z', + updatedAt: '2026-07-18T00:00:00.000Z', +}; + +// 合言葉限定公開の項目(#1109)。他人の限定公開を合言葉で内輪取り込みする想定。 +const limitedItem = { + ...publishedItem, + visibility: 'limited', + passcode: 'abc234', + status: 'published', +}; + +describe('みんなの課題 (issue #1068)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + let validateSharedAttributes: (body: Record) => Record; + let validateSupplementUrl: (value: unknown) => string | null; + let buildSharedSnapshot: ( + assignment: Record | undefined, sharedId: string, + ) => { pages: unknown[]; starterKey?: string; copies: { from: string; to: string }[] }; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + mockS3Send.mockResolvedValue({ ContentLength: 1024 }); + const mod = require('../handler'); + handler = mod.handler; + validateSharedAttributes = mod.validateSharedAttributes; + validateSupplementUrl = mod.validateSupplementUrl; + buildSharedSnapshot = mod.buildSharedSnapshot; + }); + + const commandNames = () => mockSend.mock.calls.map((c) => c[0]?.constructor?.name); + + describe('validators (pure)', () => { + test('validateSharedAttributes accepts the controlled vocabulary', () => { + expect(validateSharedAttributes({ + schoolLevel: 'high', + subject: '情報Ⅰ', + grades: [2, 1], + tags: [' AI '], + lessonCount: 5, + })).toEqual({ + schoolLevel: 'high', + subject: '情報Ⅰ', + grades: [1, 2], + tags: ['AI'], + lessonCount: 5, + }); + }); + + test('validateSharedAttributes rejects out-of-vocabulary and out-of-range values', () => { + expect(() => validateSharedAttributes({ schoolLevel: 'university', subject: 'x' })).toThrow('schoolLevel'); + expect(() => validateSharedAttributes({ schoolLevel: 'high', subject: '体育' })).toThrow('subject'); + expect(() => validateSharedAttributes({ schoolLevel: 'high', subject: '情報Ⅰ', grades: [4] })).toThrow('grades'); + expect(() => validateSharedAttributes({ + schoolLevel: 'high', subject: '情報Ⅰ', tags: ['a', 'b', 'c', 'd', 'e', 'f'], + })).toThrow('tags'); + expect(() => validateSharedAttributes({ + schoolLevel: 'high', subject: '情報Ⅰ', lessonCount: 0, + })).toThrow('lessonCount'); + }); + + test('validateSharedAttributes lets the "other" school level use free-text subjects', () => { + expect(validateSharedAttributes({ schoolLevel: 'other', subject: '高専 情報工学' }).subject) + .toBe('高専 情報工学'); + }); + + test('validateSupplementUrl enforces https and length', () => { + expect(validateSupplementUrl('https://example.com/plan')).toBe('https://example.com/plan'); + expect(validateSupplementUrl(undefined)).toBeNull(); + expect(validateSupplementUrl('')).toBeNull(); + expect(() => validateSupplementUrl('http://example.com')).toThrow('https'); + expect(() => validateSupplementUrl('not a url')).toThrow('valid URL'); + expect(() => validateSupplementUrl(`https://example.com/${'a'.repeat(500)}`)).toThrow('500'); + }); + + test('buildSharedSnapshot rewrites keys into the shared prefix', () => { + const { pages, starterKey, copies } = buildSharedSnapshot(ownClassroom.assignment, 's1'); + expect(pages).toEqual([ + { text: 'ページ1', imageKey: 'shared/s1/image-abc.png' }, + { text: 'ページ2' }, + ]); + expect(starterKey).toBe('shared/s1/starter-xyz.sb3'); + expect(copies).toEqual([ + { from: 'c1/assignment/image-abc.png', to: 'shared/s1/image-abc.png' }, + { from: 'c1/assignment/starter-xyz.sb3', to: 'shared/s1/starter-xyz.sb3' }, + ]); + }); + }); + + describe('POST /shared-assignments (share)', () => { + const shareMocks = (classroom: Record | null = ownClassroom) => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return { Item: classroom }; + if (name === 'UpdateCommand') return { Attributes: { count: 1 } }; + return {}; + }); + }; + + test('publishes a snapshot: S3 copies + Put, response has no authorSub', async () => { + shareMocks(); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: shareBody, + })); + + expect(res.statusCode).toBe(201); + const published = JSON.parse(res.body as string); + expect(published.title).toBe('ねこあつめ入門'); + expect(published.hasStarter).toBe(true); + expect(published.pageCount).toBe(2); + expect(published.authorSub).toBeUndefined(); + + // 1 HeadObject (size cap) + 2 CopyObject (image + starter). + const s3Names = mockS3Send.mock.calls.map((c) => c[0]?.constructor?.name); + expect(s3Names.filter((n) => n === 'CopyObjectCommand')).toHaveLength(2); + expect(s3Names).toContain('HeadObjectCommand'); + expect(commandNames()).toContain('PutCommand'); + }); + + test('rejects a share without license consent, before touching anything', async () => { + shareMocks(); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: { ...shareBody, licenseConsent: false }, + })); + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + + test('rejects an http supplement URL', async () => { + shareMocks(); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: { ...shareBody, supplementUrl: 'http://example.com' }, + })); + expect(res.statusCode).toBe(400); + }); + + test('rejects sharing someone else\'s classroom (401)', async () => { + shareMocks({ ...ownClassroom, teacherSub: 'owner-A' }); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: shareBody, + })); + expect(res.statusCode).toBe(401); + expect(commandNames()).not.toContain('PutCommand'); + }); + + test('rejects a classroom without content (400)', async () => { + shareMocks({ ...ownClassroom, assignment: { pages: [] } }); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: shareBody, + })); + expect(res.statusCode).toBe(400); + }); + + test('rejects an oversized starter project (D11)', async () => { + shareMocks(); + mockS3Send.mockResolvedValue({ ContentLength: 51 * 1024 * 1024 }); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: shareBody, + })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body as string).error).toContain('50MB'); + }); + + test('enforces the daily share quota (D12)', async () => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return { Item: ownClassroom }; + if (name === 'UpdateCommand') return { Attributes: { count: 11 } }; + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: shareBody, + })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body as string).error).toContain('Daily limit'); + expect(commandNames()).not.toContain('PutCommand'); + }); + }); + + describe('GET /shared-assignments (catalog)', () => { + test('lists published items newest-first without exposing authorSub', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + expect(command.input?.IndexName).toBe('status-createdAt-index'); + expect(command.input?.ScanIndexForward).toBe(false); + return { Items: [publishedItem] }; + }); + + const res = await handler(makeEvent('GET', '/shared-assignments', {}, { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + const { items, cursor } = JSON.parse(res.body as string); + expect(items).toHaveLength(1); + expect(items[0].authorName).toBe('すもう るびお'); + expect(items[0].authorSub).toBeUndefined(); + expect(items[0].hasStarter).toBe(true); + expect(cursor).toBeNull(); + }); + + test('builds a FilterExpression from the attribute filters', async () => { + let captured: Record | undefined; + mockSend.mockImplementation(async (command: { input?: Record }) => { + captured = command.input; + return { Items: [] }; + }); + + await handler(makeEvent('GET', '/shared-assignments', {}, { + token: DEV_TOKEN, + query: { schoolLevel: 'junior-high', subject: '技術・家庭(技術分野)', grade: '2', tag: '甲子園' }, + })); + + // 公開カタログは限定公開(合言葉)を除外する句が末尾に付く(#1109)。 + expect(captured?.FilterExpression).toBe( + '#sl = :sl AND #sub = :sub AND contains(#gr, :gr) AND contains(#tg, :tg) AND ' + + '(attribute_not_exists(#vis) OR #vis = :pub)', + ); + expect((captured?.ExpressionAttributeValues as Record)[':gr']).toBe(2); + }); + + test('mine=1 lists the caller\'s own items via the author GSI', async () => { + let captured: Record | undefined; + mockSend.mockImplementation(async (command: { input?: Record }) => { + captured = command.input; + return { Items: [{ ...publishedItem, authorSub: 'dev-test-teacher', status: 'unlisted' }] }; + }); + + const res = await handler(makeEvent('GET', '/shared-assignments', {}, { + token: DEV_TOKEN, query: { mine: '1' }, + })); + expect(captured?.IndexName).toBe('authorSub-createdAt-index'); + expect((captured?.ExpressionAttributeValues as Record)[':pk']).toBe('dev-test-teacher'); + // DynamoDB rejects an EMPTY ExpressionAttributeNames map — with no + // filters and no #status alias, the key must be omitted entirely + // (prod 500 on 自分の投稿, 2026-07-18). + expect(captured?.ExpressionAttributeNames).toBeUndefined(); + expect(JSON.parse(res.body as string).items[0].status).toBe('unlisted'); + }); + }); + + describe('GET /shared-assignments/{id} (detail)', () => { + test('returns pages with presigned URLs for a published item', async () => { + mockSend.mockResolvedValue({ Item: publishedItem }); + const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(res.statusCode).toBe(200); + const detail = JSON.parse(res.body as string); + expect(detail.pages).toEqual([ + { text: 'ページ1', imageUrl: 'https://signed.example/get' }, + { text: 'ページ2', imageUrl: null }, + ]); + expect(detail.starterUrl).toBe('https://signed.example/get'); + expect(detail.isMine).toBe(false); + expect(detail.authorSub).toBeUndefined(); + }); + + test('hides an unlisted item from strangers (404) but not from its author', async () => { + mockSend.mockResolvedValue({ Item: { ...publishedItem, status: 'unlisted' } }); + const strangers = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(strangers.statusCode).toBe(404); + + mockSend.mockResolvedValue({ + Item: { ...publishedItem, status: 'unlisted', authorSub: 'dev-test-teacher' }, + }); + const author = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(author.statusCode).toBe(200); + expect(JSON.parse(author.body as string).isMine).toBe(true); + }); + }); + + describe('POST /shared-assignments/{id}/import', () => { + const importMocks = () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + if (name === 'GetCommand' && command.input?.TableName?.toString().includes('SharedAssignments')) { + return { Item: publishedItem }; + } + if (name === 'GetCommand') { + return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', studentCount: 32 } }; + } + if (name === 'QueryCommand') return { Items: [] }; // joinCode uniqueness + return {}; + }); + }; + + test('creates a classroom in the caller\'s group and bumps reuseCount', async () => { + importMocks(); + const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { groupId: 'g1' }, + })); + + expect(res.statusCode).toBe(201); + const created = JSON.parse(res.body as string); + expect(created.className).toBe('2年1組'); + expect(created.assignmentName).toBe('ねこあつめ入門'); + expect(created.studentCount).toBe(32); + expect(created.hasAssignment).toBe(true); + + // Copies flow from the shared bucket into the classroom bucket. + const copyCalls = mockS3Send.mock.calls.filter((c) => c[0]?.constructor?.name === 'CopyObjectCommand'); + expect(copyCalls).toHaveLength(2); + expect(copyCalls[0][0].input.CopySource).toContain('shared%2Fs1%2F'); + expect(commandNames()).toContain('PutCommand'); + expect(commandNames()).toContain('UpdateCommand'); // reuseCount ADD + }); + + test('rejects importing into someone else\'s group (404)', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + if (command.constructor.name === 'GetCommand' && + command.input?.TableName?.toString().includes('SharedAssignments')) { + return { Item: publishedItem }; + } + return { Item: { groupId: 'g1', teacherSub: 'owner-A' } }; + }); + const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { groupId: 'g1' }, + })); + expect(res.statusCode).toBe(404); + expect(commandNames()).not.toContain('PutCommand'); + }); + + test('rejects importing an unlisted item (404)', async () => { + mockSend.mockResolvedValue({ Item: { ...publishedItem, status: 'unlisted' } }); + const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { groupId: 'g1' }, + })); + expect(res.statusCode).toBe(404); + }); + }); + + describe('限定公開・合言葉 (#1109)', () => { + test('限定公開は同意なしで発行され、passcode と visibility を返す', async () => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return { Item: ownClassroom }; + if (name === 'UpdateCommand') return { Attributes: { count: 1 } }; + if (name === 'QueryCommand') return { Items: [] }; // passcode uniqueness + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments', {}, { + token: DEV_TOKEN, body: { classroomId: 'c1', title: 'ためし課題', visibility: 'limited' }, + })); + expect(res.statusCode).toBe(201); + const pub = JSON.parse(res.body as string); + expect(pub.visibility).toBe('limited'); + expect(typeof pub.passcode).toBe('string'); + expect(pub.passcode).toHaveLength(6); + const put = mockSend.mock.calls.find((c) => c[0]?.constructor?.name === 'PutCommand'); + expect((put?.[0].input.Item as Record).visibility).toBe('limited'); + expect((put?.[0].input.Item as Record).passcode).toBe(pub.passcode); + }); + + test('公開カタログは限定公開を除外する FilterExpression を持つ', async () => { + let captured: Record | undefined; + mockSend.mockImplementation(async (command: { input?: Record }) => { + captured = command.input; + return { Items: [] }; + }); + await handler(makeEvent('GET', '/shared-assignments', {}, { token: DEV_TOKEN })); + expect(captured?.FilterExpression).toContain('attribute_not_exists(#vis) OR #vis = :pub'); + expect((captured?.ExpressionAttributeValues as Record)[':pub']).toBe('public'); + }); + + test('mine=1 は自分の限定公開の合言葉を含める', async () => { + mockSend.mockImplementation(async () => ({ + Items: [{ ...limitedItem, authorSub: 'dev-test-teacher' }], + })); + const res = await handler(makeEvent('GET', '/shared-assignments', {}, { + token: DEV_TOKEN, query: { mine: '1' }, + })); + const { items } = JSON.parse(res.body as string); + expect(items[0].visibility).toBe('limited'); + expect(items[0].passcode).toBe('abc234'); + }); + + test('合言葉ルックアップは summary を返し sharedId / passcode は出さない', async () => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + if (command.constructor.name === 'QueryCommand') return { Items: [limitedItem] }; + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments/lookup', {}, { + token: DEV_TOKEN, body: { passcode: 'abc234' }, + })); + expect(res.statusCode).toBe(200); + const s = JSON.parse(res.body as string); + expect(s.title).toBe(limitedItem.title); + expect(s.sharedId).toBeUndefined(); + expect(s.passcode).toBeUndefined(); + }); + + test('合言葉取り込みはクラスを作成し reuseCount を増やす', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const idx = command.input?.IndexName; + if (name === 'QueryCommand' && idx === 'passcode-index') return { Items: [limitedItem] }; + if (name === 'QueryCommand' && idx === 'joinCode-index') return { Items: [] }; + if (name === 'GetCommand') { + return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', studentCount: 32 } }; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments/import-by-passcode', {}, { + token: DEV_TOKEN, body: { passcode: 'abc234', groupId: 'g1' }, + })); + expect(res.statusCode).toBe(201); + const created = JSON.parse(res.body as string); + expect(created.className).toBe('2年1組'); + expect(commandNames()).toContain('PutCommand'); + expect(commandNames()).toContain('UpdateCommand'); + }); + + test('不明な合言葉は 404', async () => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + if (command.constructor.name === 'QueryCommand') return { Items: [] }; + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments/import-by-passcode', {}, { + token: DEV_TOKEN, body: { passcode: 'zzz999', groupId: 'g1' }, + })); + expect(res.statusCode).toBe(404); + }); + + test('限定公開は sharedId を知っていても非著者には 404', async () => { + mockSend.mockResolvedValue({ Item: limitedItem }); // authorSub 'someone-else' + const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(res.statusCode).toBe(404); + }); + + test('限定公開→全体公開は同意が無ければ 400、あれば 200', async () => { + const mine = { ...limitedItem, authorSub: 'dev-test-teacher' }; + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return { Item: mine }; + if (name === 'UpdateCommand') return { Attributes: { ...mine, visibility: 'public' } }; + if (name === 'QueryCommand') return { Items: [] }; + return {}; + }); + const noConsent = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { visibility: 'public' }, + })); + expect(noConsent.statusCode).toBe(400); + + const withConsent = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { visibility: 'public', licenseConsent: true }, + })); + expect(withConsent.statusCode).toBe(200); + }); + }); + + describe('PATCH / DELETE /shared-assignments/{id} (author only)', () => { + test('the author updates metadata and can republish', async () => { + const mine = { ...publishedItem, authorSub: 'dev-test-teacher', status: 'unlisted' }; + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetCommand') return { Item: mine }; + if (command.constructor.name === 'UpdateCommand') { + return { Attributes: { ...mine, title: '改訂版', status: 'published' } }; + } + return {}; + }); + const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { title: '改訂版', status: 'published' }, + })); + expect(res.statusCode).toBe(200); + const updated = JSON.parse(res.body as string); + expect(updated.title).toBe('改訂版'); + expect(updated.status).toBe('published'); + expect(updated.authorSub).toBeUndefined(); + }); + + test('a stranger cannot update or unlist (404, no write)', async () => { + mockSend.mockResolvedValue({ Item: publishedItem }); // authorSub: someone-else + const patch = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { title: '乗っ取り' }, + })); + expect(patch.statusCode).toBe(404); + + const del = await handler(makeEvent('DELETE', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(del.statusCode).toBe(404); + expect(commandNames()).not.toContain('UpdateCommand'); + }); + + test('the author unlists their item (204)', async () => { + mockSend.mockImplementation(async (command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetCommand') { + return { Item: { ...publishedItem, authorSub: 'dev-test-teacher' } }; + } + return {}; + }); + const res = await handler(makeEvent('DELETE', '/shared-assignments/s1', { sharedId: 's1' }, { + token: DEV_TOKEN, + })); + expect(res.statusCode).toBe(204); + expect(commandNames()).toContain('UpdateCommand'); + }); + }); + + describe('POST /shared-assignments/{id}/report', () => { + test('stores a report with the reason (201)', async () => { + let putItem: Record | undefined; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + if (name === 'GetCommand') return { Item: publishedItem }; + if (name === 'UpdateCommand') return { Attributes: { count: 1 } }; + if (name === 'PutCommand') { + putItem = command.input?.Item as Record; + return {}; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/shared-assignments/s1/report', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { reason: '不適切な内容が含まれています' }, + })); + expect(res.statusCode).toBe(201); + expect(putItem?.reason).toBe('不適切な内容が含まれています'); + expect(putItem?.reporterSub).toBe('dev-test-teacher'); + expect(typeof putItem?.ttl).toBe('number'); + }); + + test('rejects an empty reason (400)', async () => { + const res = await handler(makeEvent('POST', '/shared-assignments/s1/report', { sharedId: 's1' }, { + token: DEV_TOKEN, body: { reason: '' }, + })); + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts b/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts new file mode 100644 index 0000000000..db0af9e3cf --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts @@ -0,0 +1,85 @@ +/** + * Moderation CLI planning tests (issue #1071). + */ +import { + groupReports, + parseAdminArgs, + renderReportQueue, + renderSharedItem, +} from '../shared-admin-lib'; + +describe('parseAdminArgs', () => { + test('parses the four commands, dry-run by default', () => { + expect(parseAdminArgs(['list-reports'])).toEqual({ command: 'list-reports', sharedId: null, apply: false }); + expect(parseAdminArgs(['show', 's1'])).toEqual({ command: 'show', sharedId: 's1', apply: false }); + expect(parseAdminArgs(['unpublish', 's1', '--apply'])).toEqual({ + command: 'unpublish', sharedId: 's1', apply: true, + }); + expect(parseAdminArgs(['republish', 's1'])).toEqual({ command: 'republish', sharedId: 's1', apply: false }); + }); + + test('rejects unknown commands, unknown flags and missing ids', () => { + expect(() => parseAdminArgs(['delete', 's1'])).toThrow('Usage'); + expect(() => parseAdminArgs(['unpublish'])).toThrow('requires a sharedId'); + expect(() => parseAdminArgs(['show', 's1', '--force'])).toThrow('Unknown argument'); + }); +}); + +describe('groupReports', () => { + test('groups by sharedId, most-reported first, newest report first inside', () => { + const grouped = groupReports([ + { sharedId: 'a', reason: 'r1', createdAt: '2026-07-18T01:00:00Z' }, + { sharedId: 'b', reason: 'r2', createdAt: '2026-07-18T02:00:00Z' }, + { sharedId: 'b', reason: 'r3', createdAt: '2026-07-18T03:00:00Z' }, + ]); + expect([...grouped.keys()]).toEqual(['b', 'a']); + expect(grouped.get('b')!.map((r) => r.reason)).toEqual(['r3', 'r2']); + }); +}); + +describe('renderReportQueue', () => { + test('shows title/status per reported item and never the reporter', () => { + const grouped = groupReports([ + { sharedId: 's1', reason: '不適切', createdAt: '2026-07-18T00:00:00Z' }, + ]); + const lines = renderReportQueue(grouped, new Map([ + ['s1', { title: 'ねこあつめ', status: 'published' }], + ])); + expect(lines[0]).toContain('ねこあつめ'); + expect(lines[0]).toContain('status=published'); + expect(lines[1]).toContain('不適切'); + expect(lines.join('\n')).not.toContain('reporterSub'); + }); + + test('handles an empty queue and deleted items', () => { + expect(renderReportQueue(new Map(), new Map())).toEqual(['通報はありません。']); + const grouped = groupReports([{ sharedId: 'gone', reason: 'x', createdAt: '' }]); + expect(renderReportQueue(grouped, new Map())[0]).toContain('(削除済み/不明)'); + }); +}); + +describe('renderSharedItem', () => { + test('renders the public fields and truncated page texts', () => { + const lines = renderSharedItem({ + sharedId: 's1', + title: 'ねこあつめ', + status: 'published', + authorName: 'るびお', + authorAffiliation: '島根県', + schoolLevel: 'junior-high', + subject: '技術・家庭(技術分野)', + grades: [1, 2], + tags: ['甲子園'], + supplementUrl: 'https://example.com', + reuseCount: 3, + createdAt: 'c', + updatedAt: 'u', + content: { pages: [{ text: 'ページ1\n続き' }], starterKey: 'shared/s1/starter.sb3' }, + }); + const text = lines.join('\n'); + expect(text).toContain('るびお(島根県)'); + expect(text).toContain('学年 1・2'); + expect(text).toContain('starter: あり'); + expect(text).toContain('1. ページ1 / 続き'); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 3e05b2a8aa..15e7f7f391 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -19,7 +19,10 @@ export class ClassroomStack extends cdk.Stack { public readonly submissionsTable: dynamodb.Table; public readonly kickRequestsTable: dynamodb.Table; public readonly groupsTable: dynamodb.Table; + public readonly sharedAssignmentsTable: dynamodb.Table; + public readonly sharedReportsTable: dynamodb.Table; public readonly submissionsBucket: s3.Bucket; + public readonly sharedBucket: s3.Bucket; public readonly api: apigatewayv2.HttpApi; constructor(scope: Construct, id: string, props?: cdk.StackProps) { @@ -247,6 +250,88 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.groupsTable).add('ResourceType', 'DynamoDB'); + // --- みんなの課題 (shared assignment library, EPIC #1066) --- + // Shared items are nationwide teacher contributions: permanent (no TTL) + // and RETAINed in prod so a stack replacement can never destroy them + // (deliberately different from the DESTROY classroom tables). + + const sharedRemovalPolicy = stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY; + + this.sharedAssignmentsTable = new dynamodb.Table(this, 'SharedAssignmentsTable', { + tableName: `SharedAssignments${stageSuffix}`, + partitionKey: { + name: 'sharedId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: sharedRemovalPolicy, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: stage === 'prod', + }, + }); + + // Newest-first catalog (D8). + this.sharedAssignmentsTable.addGlobalSecondaryIndex({ + indexName: 'status-createdAt-index', + partitionKey: { + name: 'status', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'createdAt', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // "自分の投稿" tab: an author lists their own items incl. unlisted ones. + this.sharedAssignmentsTable.addGlobalSecondaryIndex({ + indexName: 'authorSub-createdAt-index', + partitionKey: { + name: 'authorSub', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'createdAt', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // 合言葉(限定公開)での取り込み(#1109): passcode で一意ルックアップする。 + // limited 項目のみ passcode を持つのでスパースな索引になる。 + this.sharedAssignmentsTable.addGlobalSecondaryIndex({ + indexName: 'passcode-index', + partitionKey: { + name: 'passcode', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + cdk.Tags.of(this.sharedAssignmentsTable).add('ResourceType', 'DynamoDB'); + + // Reports are ephemeral moderation inputs (90-day TTL, D3). + this.sharedReportsTable = new dynamodb.Table(this, 'SharedReportsTable', { + tableName: `SharedAssignmentReports${stageSuffix}`, + partitionKey: { + name: 'sharedId', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'reportId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: false, + }, + timeToLiveAttribute: 'ttl', + }); + + cdk.Tags.of(this.sharedReportsTable).add('ResourceType', 'DynamoDB'); + // --- S3 Bucket for submissions --- this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', { @@ -275,6 +360,29 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.submissionsBucket).add('ResourceType', 'S3'); + // --- S3 Bucket for shared assignments (みんなの課題) --- + // No lifecycle rule: shared content is permanent (D7). The classroom + // bucket's retention sweep must never touch these objects, hence the + // separate bucket. RETAINed in prod like the table. + + this.sharedBucket = new s3.Bucket(this, 'SharedAssignmentsBucket', { + bucketName: `smalruby-shared-assignments${stageSuffix}`, + removalPolicy: sharedRemovalPolicy, + autoDeleteObjects: stage !== 'prod', + cors: [ + { + allowedMethods: [s3.HttpMethods.GET], + allowedOrigins: corsAllowOrigins, + allowedHeaders: ['Content-Type'], + maxAge: 3600, + }, + ], + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + }); + + cdk.Tags.of(this.sharedBucket).add('ResourceType', 'S3'); + // --- Lambda --- const logGroup = new logs.LogGroup(this, 'ClassroomHandlerLogGroup', { @@ -297,7 +405,12 @@ export class ClassroomStack extends cdk.Stack { SUBMISSIONS_TABLE_NAME: this.submissionsTable.tableName, KICK_REQUESTS_TABLE_NAME: this.kickRequestsTable.tableName, GROUPS_TABLE_NAME: this.groupsTable.tableName, + SHARED_ASSIGNMENTS_TABLE_NAME: this.sharedAssignmentsTable.tableName, + SHARED_REPORTS_TABLE_NAME: this.sharedReportsTable.tableName, SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName, + SHARED_BUCKET_NAME: this.sharedBucket.bucketName, + SHARE_DAILY_LIMIT: process.env.SHARE_DAILY_LIMIT || '10', + REPORT_DAILY_LIMIT: process.env.REPORT_DAILY_LIMIT || '20', GOOGLE_CLIENT_ID: googleClientId, MICROSOFT_CLIENT_ID: microsoftClientId, DEV_BYPASS_TOKEN: devBypassToken, @@ -330,8 +443,13 @@ export class ClassroomStack extends cdk.Stack { this.submissionsTable.grantReadWriteData(handlerFn); this.kickRequestsTable.grantReadWriteData(handlerFn); this.groupsTable.grantReadWriteData(handlerFn); + this.sharedAssignmentsTable.grantReadWriteData(handlerFn); + this.sharedReportsTable.grantReadWriteData(handlerFn); this.submissionsBucket.grantPut(handlerFn); this.submissionsBucket.grantRead(handlerFn); + this.sharedBucket.grantPut(handlerFn); + this.sharedBucket.grantRead(handlerFn); + this.sharedBucket.grantDelete(handlerFn); // --- Delete-snapshot archiver (issue #1053) --- // Streams on the four data tables feed every REMOVE (TTL sweep or @@ -535,6 +653,45 @@ export class ClassroomStack extends cdk.Stack { integration, }); + // みんなの課題 (shared assignment library) — own root path. + this.api.addRoutes({ + path: '/shared-assignments', + methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.GET], + integration, + }); + + this.api.addRoutes({ + path: '/shared-assignments/{sharedId}', + methods: [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH, apigatewayv2.HttpMethod.DELETE], + integration, + }); + + this.api.addRoutes({ + path: '/shared-assignments/{sharedId}/import', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + + this.api.addRoutes({ + path: '/shared-assignments/{sharedId}/report', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + + // 合言葉(限定公開)による preview / 取り込み(#1109)。POST の literal path + // なので GET/PATCH/DELETE の /{sharedId} ルートとは衝突しない。 + this.api.addRoutes({ + path: '/shared-assignments/lookup', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + + this.api.addRoutes({ + path: '/shared-assignments/import-by-passcode', + 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/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index a5c156bcbb..c4ba58e5be 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -109,6 +109,7 @@ src/containers/* !src/containers/use-teacher-auth.js !src/containers/use-teacher-classroom.js !src/containers/use-teacher-classrooms.js +!src/containers/use-shared-assignments.js !src/containers/use-teacher-evaluation.js !src/containers/use-teacher-groups.js !src/containers/use-teacher-submissions.js @@ -149,6 +150,8 @@ src/lib/* !src/lib/classroom-download-utils.js !src/lib/classroom-group-utils.js !src/lib/classroom-retention.js +!src/lib/shared-assignment-taxonomy.js +!src/lib/shared-author-profile.js !src/lib/classroom-kick-request-storage.js !src/lib/google-classroom-auth.js !src/lib/block-utils.js @@ -381,6 +384,8 @@ test/unit/lib/* !test/unit/lib/classroom-download-utils.test.js !test/unit/lib/classroom-group-utils.test.js !test/unit/lib/classroom-retention.test.js +!test/unit/lib/shared-assignment-taxonomy.test.js +!test/unit/lib/shared-author-profile.test.js !test/unit/lib/evaluation-utils.test.js !test/unit/lib/sb3-analyzer.test.js !test/unit/lib/classroom-kick-request-storage.test.js diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css index 6e6f3811b4..44075ba0a0 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -140,6 +140,7 @@ font-size: 0.9rem; cursor: pointer; font-weight: bold; + text-decoration: none; } .primary-button:hover { @@ -278,25 +279,186 @@ margin: 0.3rem 0; } +/* 課題名(左)と参加コード(右)を均等幅 2 カラムに分ける。両カラムは + stretch で高さが揃うので、課題名入力の高さは参加コードカードに一致する。 + 幅が足りず参加コードカードが 340px を割り込むと、行が折り返して参加コード + カードは単独行(フル幅)になり、狭いウインドウでも 2 行に収まる。 */ +.name-code-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: stretch; + margin: 0.3rem 0; +} + +/* 課題名: ラベルと 2 行入力を横並び。ラベルは縦中央、入力はカラム(=カード) + 高さいっぱいに伸ばして参加コードカードと高さを揃える。 */ +.name-col { + flex: 1 1 340px; + min-width: 0; + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.3rem; +} + +.name-col .assignment-name-label { + flex: 0 0 auto; + align-self: center; + white-space: nowrap; +} + +/* 課題名は 2 行分の入力領域(長い名前が折り返して見える)。カード高さに + 合わせて縦に伸びる。単一行の値なので monospace を避け、リサイズは無効。 */ +.name-col .assignment-name-input { + flex: 1 1 auto; + min-width: 0; + min-height: 2.8rem; + box-sizing: border-box; + resize: none; + font-family: inherit; + line-height: 1.3; + overflow-y: auto; +} + +/* 参加コードカード (redesign): 右カラム。コードと 3 つのボタンを 1 つの + flex flow に並べ、同じ行から始めることで(幅が足りなくても)2 行に収める。 */ +.join-code-card { + flex: 1 1 340px; + min-width: 0; + container-type: inline-size; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.35rem 0.4rem; + background-color: #f2f2f2; + border: 2px dashed #d9d9d9; + border-radius: 8px; + padding: 0.4rem 0.5rem; +} + +/* 参加コード(ラベル + 値)は折り返さず 1 単位として先頭に置く。 */ +.join-code-codepart { + flex: 0 0 auto; + display: inline-flex; + align-items: baseline; + gap: 0.2rem; + white-space: nowrap; +} + +.join-code-action { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.7rem; + border: 1px solid #c8cdd8; + border-radius: 6px; + background-color: #fff; + color: #575e75; + font-size: 0.85rem; + line-height: 1.2; + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} + +.join-code-action:hover { + background-color: #f6f7fa; +} + +/* Google Classroom への共有は主要導線として強調 */ +.join-code-action-gc { + border-color: #4c97ff; + background-color: #e5f0ff; + color: #3373cc; + font-weight: bold; +} + +.join-code-action-gc:hover { + background-color: #d6e7ff; +} + +/* コピー完了のリアクション: アイコンがチェックに変わり緑で強調 */ +.join-code-action-copied { + border-color: #4caf50; + background-color: #eafaea; + color: #4caf50; +} + +.join-code-action-copied:hover { + background-color: #d9f5d9; +} + +.join-code-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; +} + +/* 値・ラベルは課題名ラベル (1rem) と同サイズ(値はボールド・色を維持)。 */ .join-code-value { - font-size: 1.5rem; + font-size: 1rem; font-weight: bold; font-family: monospace; - letter-spacing: 0.3em; + letter-spacing: 0.15em; color: #4c97ff; user-select: text; cursor: text; } +.join-code-label { + font-size: 1rem; + color: #575e75; +} + +/* 全画面表示・招待リンクコピーは幅に関わらずアイコンのみ、Google Classroom + 共有は "[アイコン]に共有" の短縮表示で固定。ラベル切り替えの container query + は廃止し、常にコンパクトにしてレイアウト崩れを防ぐ。 */ +.join-code-gc-label { + font-size: 0.85rem; +} + +/* 保存期限 + 期限警告を1行に。狭い幅では末尾ヒントを省く container query。 */ .expires-at-text { + container-type: inline-size; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; font-size: 0.8rem; color: #999; margin: 0.25rem 0 0.5rem; } -.join-code-label { - font-size: 0.75rem; - color: #575e75; +.expires-at-date { + flex: 0 0 auto; +} + +/* 期限が近いときの警告メッセージ(保存期限の右側にインライン表示)。 */ +.expires-at-alert { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.15rem 0.3rem; + font-weight: bold; + color: hsla(30, 70%, 35%, 1); +} + +.expires-at-warning .expires-at-alert { + color: hsla(10, 80%, 42%, 1); +} + +.expires-at-alert-mark { + align-self: center; +} + +/* 幅が狭ければ「ダウンロードして保存してください」を省いて領域を確保。 */ +@container (max-width: 520px) { + .expires-at-hint { + display: none; + } } .assignment-name-label { @@ -356,16 +518,17 @@ background-color: #e9f0ff; } -.success-message { - color: #0fbd8c; - font-weight: bold; - text-align: center; - padding: 2rem 0; - font-size: 1.1rem; +/* 配信画面の本文コンテナ。課題詳細(.detail-layout)と同じ左右オフセットに + 合わせる(上端はパンくずラッパーが持つ)。旧 .phaseContainer は未定義で + 左マージンが出ていなかったため専用クラスに置き換え。 */ +.post-assignment-container { + padding: 0 2rem 1.25rem; } +/* クラス名(課題詳細と同じ表記)を配信画面のサブタイトルとして表示。 */ .post-assignment-target { - font-size: 0.9rem; + font-size: 1rem; + font-weight: bold; color: #575e75; margin-bottom: 1rem; } @@ -378,20 +541,105 @@ margin-bottom: 1rem; } -.post-assignment-success-area { - text-align: center; - padding: 2rem 0; +/* 配信後の成功タイトル(基本レイアウトに合わせ左寄せ・緑で強調)。 */ +.post-assignment-success-title { + color: #0fbd8c; + font-weight: bold; + font-size: 1.1rem; + margin-bottom: 0.5rem; } -.post-assignment-actions { - margin-bottom: 1rem; +/* フォーム下部の共通フッター: キャンセル(左)+ プライマリー(右)。 + クラス管理の各入力フォームで共有するポリシー(プライマリー右下)。 */ +.form-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 1.5rem; +} + +/* 共有設定ステップ(#1109): 公開範囲の選択カード + 合言葉の表示。 */ +.share-mode-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 0.5rem 0 1rem; +} + +.share-mode-card { + flex: 1 1 260px; + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.7rem 0.9rem; + border: 2px solid #d9d9d9; + border-radius: 8px; + background: #fff; + cursor: pointer; + text-align: left; } -.post-assignment-hint-center { +.share-mode-card:hover { + border-color: #b3c7ff; +} + +.share-mode-card-active { + border-color: #4c97ff; + background: #e9f0ff; +} + +.share-mode-name { + font-weight: bold; + color: #575e75; +} + +.share-mode-desc { font-size: 0.8rem; color: #888; - line-height: 1.5; - text-align: center; +} + +.share-passcode-box { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.8rem 1rem; + margin: 0.5rem 0; + background: #f2f7ff; + border: 2px dashed #4c97ff; + border-radius: 8px; +} + +.share-passcode-label { + font-weight: bold; + color: #575e75; +} + +.share-passcode-value { + font-family: monospace; + font-size: 1.6rem; + font-weight: bold; + letter-spacing: 0.2em; + color: #3373cc; + user-select: text; + cursor: text; +} + +/* 合言葉で取り込み(#1109・受け取る側)のプレビュー / エラー。 */ +.passcode-preview { + padding: 0.6rem 0.9rem; + margin: 0.5rem 0; + background: #e9f0ff; + border-radius: 8px; + color: #575e75; + font-weight: bold; +} + +.passcode-error { + color: hsla(10, 80%, 42%, 1); + font-size: 0.85rem; + margin: 0.3rem 0; } .class-item-main { @@ -2292,61 +2540,380 @@ /* --- Retention (auto-delete) alerts (issue #1052) --- */ -.expiry-badge-notice, -.expiry-badge-warning { - align-self: center; - flex-shrink: 0; - padding: 0.1rem 0.5rem; - border-radius: 0.75rem; - font-size: 0.75rem; - white-space: nowrap; +/* 期限が近い課題の行に出す自動削除メッセージ + 全作品ダウンロード。 + バッジ(あと N 日)は廃止し、詳細と同じ文面 + DL 動線を一覧にも置く。 */ +.board-row-retention, +.board-row-retention-warning { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.6rem; + padding: 0.1rem 0.3rem 0.2rem; + font-size: 0.8rem; + font-weight: bold; } -.expiry-badge-notice { - background: hsla(35, 90%, 55%, 0.15); +.board-row-retention { color: hsla(30, 70%, 35%, 1); } -.expiry-badge-warning { - background: hsla(20, 100%, 55%, 0.18); - color: hsla(10, 80%, 40%, 1); +.board-row-retention-warning { + color: hsla(10, 80%, 42%, 1); +} + +.board-row-retention-mark { + flex: 0 0 auto; +} + +.board-row-retention-text { + flex: 1 1 auto; + min-width: 0; +} + +.board-row-retention-download { + flex: 0 0 auto; + padding: 0.3rem 0.7rem; + border: 1px solid currentColor; + border-radius: 0.4rem; + background: white; + color: inherit; + font-weight: bold; + cursor: pointer; + white-space: nowrap; +} + +.board-row-retention-download:hover:not(:disabled) { + background: hsla(10, 85%, 55%, 0.1); } -.retention-banner, -.retention-banner-warning { +.board-row-retention-download:disabled { + opacity: 0.5; + cursor: default; +} + +/* 期限が近いとき、全作品ダウンロードボタンを警告色にしてDLを促す(#1052)。 + secondary-button / detail-tabs-download より後に定義し、element+class で + 確実に色を上書きする。 */ +button.detail-tabs-download-urgent { + border-color: hsla(10, 80%, 45%, 1); + background: hsla(10, 85%, 55%, 1); + color: white; + font-weight: bold; +} + +button.detail-tabs-download-urgent:hover:not(:disabled) { + background: hsla(10, 85%, 48%, 1); +} + +/* --- みんなの課題: share form (EPIC #1066 S2) --- */ + +.shared-form { display: flex; - align-items: center; - gap: 0.75rem; + flex-direction: column; + gap: 0.6rem; margin: 0.75rem 0; - padding: 0.6rem 0.9rem; + padding: 1rem; + border: 1px solid hsla(260, 60%, 60%, 0.35); border-radius: 0.5rem; + background: hsla(260, 60%, 60%, 0.05); +} + +.shared-form input[type='text'], +.shared-form input[type='url'], +.shared-form input[type='number'], +.shared-form select { + padding: 0.45rem 0.6rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; font-size: 0.875rem; } -.retention-banner { - background: hsla(35, 90%, 55%, 0.12); - color: hsla(30, 60%, 30%, 1); +.shared-form-title { + margin: 0; + font-size: 1rem; + color: #855cd6; +} + +.shared-form-hint { + margin: 0; + font-size: 0.8rem; + color: hsla(225, 15%, 40%, 1); +} + +.shared-form-row { + display: flex; + gap: 0.5rem; } -.retention-banner-warning { - background: hsla(20, 100%, 55%, 0.15); - color: hsla(10, 80%, 35%, 1); +.shared-form-row > * { + flex: 1; } -.retention-banner-download { - flex-shrink: 0; +.shared-form-row input[type='number'] { + max-width: 6rem; +} + +.shared-form-grades { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + font-size: 0.875rem; +} + +.shared-form-grade { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.shared-form-label { + color: hsla(225, 15%, 40%, 1); +} + +.shared-form-url-block { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.shared-form-url-block input { + width: 100%; + box-sizing: border-box; +} + +.shared-form-url-hint { + margin: 0; + font-size: 0.75rem; + color: hsla(225, 15%, 40%, 1); +} + +.shared-form-url-error { + margin: 0; + font-size: 0.75rem; + color: hsla(10, 80%, 40%, 1); +} + +.shared-form-consent { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.6rem; + border-radius: 0.4rem; + background: hsla(35, 90%, 55%, 0.1); + font-size: 0.8rem; + cursor: pointer; +} + +.shared-form-consent input { + margin-top: 0.15rem; +} + +.shared-form-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.shared-form-actions button { + padding: 0.45rem 1rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; + background: white; + cursor: pointer; +} + +.shared-form-submit { + border-color: #855cd6 !important; + background: #855cd6 !important; + color: white; +} + +.shared-form-submit:disabled { + opacity: 0.5; + cursor: default; +} + +.shared-form-success { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + border-radius: 0.4rem; + background: hsla(145, 60%, 45%, 0.12); + color: hsla(145, 70%, 25%, 1); + font-size: 0.875rem; +} + +/* --- みんなの課題: catalog (EPIC #1066 S3) --- */ + +.shared-catalog { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0.75rem 0; + padding: 1rem; + border: 1px solid hsla(260, 60%, 60%, 0.35); + border-radius: 0.5rem; + background: white; +} + +.shared-catalog-filters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.shared-catalog-filters select, +.shared-catalog-filters input, +.shared-catalog-filters button { + padding: 0.35rem 0.5rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; + background: white; + font-size: 0.8rem; +} + +.shared-cards { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.shared-card { + border: 1px solid hsla(0, 0%, 0%, 0.1); + border-radius: 0.5rem; +} + +.shared-card-main { + display: flex; + flex-direction: column; + gap: 0.3rem; + width: 100%; + padding: 0.6rem 0.8rem; + border: none; + background: none; + text-align: left; + cursor: pointer; +} + +.shared-card-main:hover { + background: hsla(260, 60%, 60%, 0.05); +} + +.shared-card-title { + font-weight: bold; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.shared-card-unlisted { + padding: 0.05rem 0.4rem; + border-radius: 0.25rem; + background: hsla(0, 0%, 0%, 0.08); + color: hsla(225, 15%, 40%, 1); + font-size: 0.7rem; + font-weight: normal; +} + +.shared-card-summary { + font-size: 0.8rem; + color: hsla(225, 15%, 40%, 1); +} + +.shared-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.shared-card-tag { + font-size: 0.75rem; + padding: 0.1rem 0.4rem; + border-radius: 0.25rem; + background: hsla(200, 70%, 55%, 0.12); + color: hsla(205, 70%, 35%, 1); +} + +.shared-card-meta { + font-size: 0.75rem; + color: hsla(225, 15%, 45%, 1); +} + +.shared-detail { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.shared-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.shared-detail-header button { padding: 0.35rem 0.8rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; + background: white; + cursor: pointer; +} + +.shared-detail-page { + padding: 0.6rem; + border: 1px solid hsla(0, 0%, 0%, 0.08); + border-radius: 0.4rem; +} + +.shared-detail-url-block button, +.shared-detail-url-confirm a, +.shared-detail-url-confirm button { + padding: 0.3rem 0.7rem; border: 1px solid #855cd6; - border-radius: 0.5rem; + border-radius: 0.4rem; background: white; color: #855cd6; cursor: pointer; - white-space: nowrap; + text-decoration: none; + font-size: 0.8rem; } -.retention-banner-download:disabled { - opacity: 0.5; - cursor: default; +.shared-detail-url-confirm { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.7rem; + border-radius: 0.4rem; + background: hsla(35, 90%, 55%, 0.12); + font-size: 0.8rem; +} + +.shared-report-form { + display: flex; + gap: 0.5rem; + align-items: flex-start; +} + +.shared-report-form textarea { + flex: 1; + min-height: 3rem; + padding: 0.45rem 0.6rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; + font-size: 0.85rem; +} + +.shared-report-form button { + padding: 0.45rem 0.8rem; + border: 1px solid hsla(0, 0%, 0%, 0.15); + border-radius: 0.4rem; + background: white; + cursor: pointer; } /* --- Assignment board (inside a class) --- */ @@ -2486,6 +3053,21 @@ gap: 0.5rem; } +/* 期限メッセージを行の下に積むため、アクティブ課題の行は縦積みにする + (アーカイブ行は従来どおり .board-row の横並びのまま)。 */ +.board-row-item { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; +} + +.board-row-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + .board-row-main { flex: 1; display: flex; @@ -2523,6 +3105,29 @@ background: white; } +/* 各行の「共有」ボタン(#1109。共有導線をボードに一本化)。 */ +.board-row-share { + flex: 0 0 auto; + padding: 0.35rem 0.7rem; + border: 1px solid #4c97ff; + border-radius: 0.3rem; + background: white; + color: #3373cc; + font-size: 0.8rem; + font-weight: bold; + cursor: pointer; + white-space: nowrap; +} + +.board-row-share:hover:not(:disabled) { + background: #e9f0ff; +} + +.board-row-share:disabled { + opacity: 0.5; + cursor: default; +} + .class-list-import-button { margin-left: 0.5rem; border: 1px solid #855cd6; @@ -2797,6 +3402,8 @@ .teacher-view-title { margin: 0.25rem 0 1rem; font-size: 1.3rem; + /* 課題一覧の見出し(h2.board-title)と同じ太字に揃える。 */ + font-weight: bold; color: #575e75; overflow: hidden; text-overflow: ellipsis; diff --git a/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx new file mode 100644 index 0000000000..73f89e5a79 --- /dev/null +++ b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx @@ -0,0 +1,575 @@ +/** + * みんなの課題 catalog (EPIC #1066, S3): browse/filter the nationwide shared + * assignment library, preview an item, import it into the current class, and + * manage the caller's own posts (unlist / republish). Supplement URLs open + * behind an explicit confirmation that names the external domain (D4). + */ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { SCHOOL_LEVELS, SUBJECTS_BY_LEVEL } from '../../lib/shared-assignment-taxonomy.js'; + +import styles from './classroom-modal.css'; + +// Static message map so the level label can be resolved at runtime from the +// taxonomy value (React Intl requires statically evaluate-able messages). +const levelMessages = defineMessages({ + elementary: { + defaultMessage: 'Elementary school', + description: 'School level choice: elementary', + id: 'gui.classroom.shared.levelElementary', + }, + 'junior-high': { + defaultMessage: 'Junior high school', + description: 'School level choice: junior high', + id: 'gui.classroom.shared.levelJuniorHigh', + }, + high: { + defaultMessage: 'High school', + description: 'School level choice: high school', + id: 'gui.classroom.shared.levelHigh', + }, + other: { + defaultMessage: 'Other', + description: 'School level choice: other', + id: 'gui.classroom.shared.levelOther', + }, +}); + +const CatalogCard = ({ item, onOpenDetail }) => { + const intl = useIntl(); + const handleOpen = useCallback(() => onOpenDetail(item.sharedId), [onOpenDetail, item.sharedId]); + return ( +
  • + +
  • + ); +}; + +CatalogCard.propTypes = { + item: PropTypes.object.isRequired, + onOpenDetail: PropTypes.func.isRequired, +}; + +const SharedAssignmentDetail = ({ + detail, + group, + isLoading, + reportSent, + onClose, + onImport, + onReport, + onSetStatus, +}) => { + const intl = useIntl(); + const [showUrlConfirm, setShowUrlConfirm] = useState(false); + const [showReport, setShowReport] = useState(false); + const [reportReason, setReportReason] = useState(''); + + const handleImport = useCallback( + () => onImport(detail.sharedId, group.groupId), + [onImport, detail.sharedId, group.groupId], + ); + const handleShowUrl = useCallback(() => setShowUrlConfirm(true), []); + const handleHideUrl = useCallback(() => setShowUrlConfirm(false), []); + const handleToggleReport = useCallback(() => setShowReport((v) => !v), []); + const handleReasonChange = useCallback((e) => setReportReason(e.target.value), []); + const handleSendReport = useCallback(() => { + if (reportReason.trim()) { + onReport(detail.sharedId, reportReason.trim()); + } + }, [onReport, detail.sharedId, reportReason]); + const handleUnlist = useCallback( + () => onSetStatus(detail.sharedId, 'unlisted'), + [onSetStatus, detail.sharedId], + ); + const handleRepublish = useCallback( + () => onSetStatus(detail.sharedId, 'published'), + [onSetStatus, detail.sharedId], + ); + + let urlDomain = ''; + if (detail.supplementUrl) { + try { + urlDomain = new URL(detail.supplementUrl).hostname; + } catch { + urlDomain = ''; + } + } + + return ( +
    +
    +

    {detail.title}

    + +
    +

    + {`© ${detail.authorName}${detail.authorAffiliation ? `(${detail.authorAffiliation})` : ''} / CC BY 4.0`} +

    + {detail.summary ?

    {detail.summary}

    : null} + + {(detail.pages || []).map((page, index) => ( +
    + {page.imageUrl ? ( + + ) : null} +

    {page.text}

    +
    + ))} + + {detail.hasStarter ? ( +

    + +

    + ) : null} + + {detail.supplementUrl ? ( +
    + {showUrlConfirm ? ( + + + + + + + + ) : ( + + )} +
    + ) : null} + +
    + {detail.isMine ? ( + detail.status === 'published' ? ( + + ) : ( + + ) + ) : ( + + )} + + {detail.status === 'published' ? ( + + ) : null} +
    + + {showReport && !reportSent ? ( +
    +