Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .claude/rules/infra/smalruby-classroom.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ CDK project for the Classroom service (API Gateway + Lambda + DynamoDB + S3).
`X-Google-Access-Token` を含む)。stage スロットリングに加え、`/classrooms/join` と
`/classrooms/lookup` はルート個別に厳しめのスロットリングを設定
- **Lambda (Node.js 22)**: 単一ハンドラー (`lambda/handler.ts`) に全ビジネスロジック
- **DynamoDB (4 テーブル)**:
- **DynamoDB (8 テーブル)**:
- `Classrooms{suffix}` — GSI `joinCode-index` / `teacherSub-index`
- `ClassroomMemberships{suffix}` — GSI `sessionToken-index`
- `ClassroomSubmissions{suffix}` — GSI `classroomId-memberId-index`
- `ClassroomKickRequests{suffix}` — GSI `classroomId-seatNumber-index`(退室依頼 #692)
- すべて TTL `ttl`・**RemovalPolicy: DESTROY**
- `ClassroomGroups{suffix}` — GSI `teacherSub-index`(クラス=学級、長期 TTL 400日)
- `SharedAssignments{suffix}` — GSI `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(みんなの課題 #1066。**TTL なし・prod RETAIN + PITR**)
- `SharedAssignmentReports{suffix}` — 通報(TTL 90日)
- `ClassroomNotifications{suffix}` — お知らせ #1111(PK teacherSub / SK notificationId。**書き手は admin スタックのみ**・この Lambda は Query/UpdateItem だけ grant)
- 上記注記のないものは TTL `ttl`・**RemovalPolicy: DESTROY**
- **S3** `smalruby-classroom-submissions{suffix}`: 提出ファイル (project.sb3, thumbnail.png,
screenshots)。lifecycle 期限 = `CLASSROOM_TTL_DAYS`
- **認証**: Google / Microsoft ID Token(`iss` 自動判別 + JWKS 検証。bug-report と同型)。
Expand Down
3 changes: 2 additions & 1 deletion docs/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
### セクション(3 ドメイン)

1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)。クラス詳細には**先生へのお知らせ送信**(EPIC #1111)があり、タイトル + 本文を二段階確認で `POST /admin/notifications` へ送ると、その課題を作成した先生のクラス管理画面右上「お知らせ」🔔 に届く(宛先 teacherSub はサーバー側で classroomId から解決し SPA には出さない。書き込み先 `ClassroomNotifications` テーブルは名前規約 import + write-only grant で、この面が単一の書き手)
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる

## 認証・認可モデル(要点)
Expand Down Expand Up @@ -90,3 +90,4 @@ cd tools/playwright-verify && node verify-admin.mjs
| `screenshots/0103-classrooms.png` | クラス・課題: 検索一覧 |
| `screenshots/0104-restore-plan.png` | 期限切れ復元: dry-run プラン |
| `screenshots/0105-bug-reports.png` | バグ報告: 閲覧一覧 |
| `screenshots/0106-classroom-notify.png` | クラス詳細: 先生へのお知らせ送信(EPIC #1111) |
Binary file added docs/admin/screenshots/0106-classroom-notify.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions docs/classroom/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ sequenceDiagram
| **DynamoDB** | `ClassroomGroups-{stage}` | クラス(学級)情報(Streams: OLD_IMAGE) |
| **DynamoDB** | `SharedAssignments-{stage}` | みんなの課題(TTL なし・prod RETAIN + PITR) |
| **DynamoDB** | `SharedAssignmentReports-{stage}` | みんなの課題の通報(TTL 90日) |
| **DynamoDB** | `ClassroomNotifications-{stage}` | お知らせ(EPIC #1111。TTL は書き手の admin スタックが付与・既定 180 日) |
| **S3** | `smalruby-shared-assignments-{stage}` | 共有課題のスナップショット(lifecycle なし = 永続・prod RETAIN) |
| **S3** | `smalruby-classroom-submissions-{stage}` | 提出ファイル (.sb3, サムネイル, スクリーンショット) + `ddb-archive/` スナップショット。lifecycle = `ARCHIVE_RETENTION_DAYS`(既定 365 日) |
| **Route53** | A レコード | カスタムドメイン |
Expand Down Expand Up @@ -187,6 +188,8 @@ sequenceDiagram
| `PATCH` | `/classroom-groups/{groupId}/topics` | トピックの add / remove / rename(rename・remove は課題へ一括追従) |
| `POST` | `/classrooms/{id}/duplicate` | クラス(授業)の複製。課題コンテンツの S3 オブジェクトもコピー。`groupId` / `className` / `assignmentName` を上書き可。メンバー・提出は複製しない |
| `POST` | `/classrooms/{id}/evaluate` | AI 評価支援。静的解析結果(シグナル + 擬似コード)を Anthropic API にリレーし、`mode: grade` は S/A/B/C 案 + 根拠 + needsReview、`mode: comment` は生徒向けポジティブコメント下書きを返す。1リクエスト最大10提出(API GW の30秒制限対策、クライアントがチャンク分割)。先生ごとにレート制限(既定 60回/時) |
| `GET` | `/notifications` | お知らせ一覧(EPIC #1111)。自分宛て(teacherSub)の直近 50 件 + `unreadCount` を返す。書き込みは admin スタックのみ(単一ライター) |
| `POST` | `/notifications/mark-read` | お知らせ既読化。`{notificationIds?}` — 省略時は一覧窓(直近 50 件)の未読を全既読化(パネルを開いたらバッジが消える UX) |

### みんなの課題 (共有課題ライブラリ・先生 ID Token 認証)

Expand Down Expand Up @@ -313,6 +316,31 @@ erDiagram
- `listMembers` / `lookup takenSeats` は `FilterExpression: attribute_not_exists(kicked) OR kicked <> :true` で tombstone を除外
- `joinClassroom` の `ConditionExpression: attribute_not_exists(memberId) OR kicked = :true` で新生徒が kicked 行を上書き可能 (tombstone はその時点で消滅)

### ClassroomNotifications テーブル(お知らせ・EPIC #1111)

```mermaid
erDiagram
ClassroomNotifications {
string teacherSub PK "宛先の先生 (Google/Microsoft sub)"
string notificationId SK "createdAt ISO + '#' + UUID (時系列ソート)"
string type "admin_message など (リンク種別の意味付け)"
string title "最大100文字"
string body "最大1000文字"
map link "任意: {kind: 'classroom', classroomId} — クリック時のジャンプ先"
string readAt "既読日時 (未読は属性なし)"
string createdBy "送信した admin の email (内部用・API では返さない)"
string createdAt "ISO8601"
number ttl "Unix timestamp (既定180日)"
}
```

**GSI なし** — 宛先 (PK) への Query だけで受信箱になる。

- **単一ライター**: 書き込みは admin スタック(`POST /admin/notifications`、名前規約 import + write-only grant)のみ。classroom API は一覧と既読化だけを提供するので、エディタ側からお知らせを偽造できない
- 宛先の `teacherSub` は admin SPA に出さず、admin API が classroomId から解決する
- `link.kind` は whitelist(現在 `classroom` のみ)。エディタは未知の kind を無視する(前方互換)
- GUI は先生モーダルのタイトルバー 🔔 から一覧を開く。パネルを開いた時点で mark-read(全件)が走りバッジが消える。60 秒間隔でポーリング

### ClassroomSubmissions テーブル

```mermaid
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/classroom/source-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ smalruby3-editor/
│ │ ├── containers/
│ │ │ ├── classroom-modal.jsx ← コンテナ(生徒/先生モードの振り分け)
│ │ │ ├── use-teacher-classroom.js ← 先生用フック統合(他フックを束ねる)
│ │ │ ├── use-teacher-notifications.js ← お知らせセンター (EPIC #1111)
│ │ │ ├── use-teacher-auth.js ← 認証フック(Google/Microsoft 共通)
│ │ │ ├── use-teacher-classrooms.js ← クラス CRUD + 自動リフレッシュ
│ │ │ ├── use-teacher-submissions.js ← 提出管理・一括ダウンロード
Expand Down Expand Up @@ -109,6 +110,8 @@ CDK スタック定義。以下のリソースを作成:
| `handleUpdateSubmission()` | 提出更新 (返却・コメント) |
| `handleSetAssignment()` | 課題コンテンツ設定 (ページ + スターター、Presigned URL 生成) |
| `handleGetAssignment()` | 課題コンテンツ取得 (生徒 Session Token / 先生 ID Token 両対応) |
| `handleListNotifications()` | お知らせ一覧 + 未読数 (EPIC #1111) |
| `handleMarkNotificationsRead()` | お知らせ既読化 (ids 省略で全既読) |
| `handleCreateGroup()` / `handleListGroups()` / `handleUpdateGroup()` | 組(グループ)の作成・一覧・更新/アーカイブ |
| `handleDuplicateClassroom()` | クラス複製(課題の S3 オブジェクトコピー含む) |
| `handleListGoogleCourses()` | Google Classroom コース一覧 |
Expand Down Expand Up @@ -180,6 +183,7 @@ GOOGLE_ID_TOKEN=eyJ... docker compose run --rm -w /app/infra/smalruby-classroom
| `teacher-login-phase.jsx` | ログイン画面(Google / Microsoft ボタン + カルーセル) |
| `teacher-sidebar.jsx` | サイドバー(クラス一覧ナビゲーション) |
| `teacher-google-courses-phase.jsx` | Google Classroom コースインポート画面 |
| `teacher-notifications.jsx` | お知らせセンター(🔔 + 未読バッジ + 一覧パネル、EPIC #1111) |

### `packages/scratch-gui/src/lib/classroom-api.js`

Expand Down
20 changes: 20 additions & 0 deletions docs/classroom/ui-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ Google または Microsoft アカウントでサインインする画面。先
- 課題の所属クラス変更・人数編集・課題単位の共同管理者・複製は**できない**(クラス設定 / 課題一覧の再利用へ集約)。フッターのボタンは「**課題をアーカイブ**」(soft-delete。ボードの「アーカイブ済みの課題」からいつでも復元可能。testid は歴史的経緯で `classroom-delete-classroom` のまま)
- 主な data-testid: `classroom-tab-description` / `classroom-description-editor` / `classroom-description-preview[-body|-prev|-next]` / `classroom-tab-members`

## 1.8 先生: お知らせセンター(タイトルバー 🔔・EPIC #1111)

![お知らせセンター](screenshots/0218-teacher-notifications.png)

クラス管理のタイトルバー右上(× の左隣)に 🔔 ボタン。運営(Admin)からのお知らせが届くと未読数バッジが付く。

- クリックで一覧パネルを開閉。**開いた時点で全既読**になりバッジが消える(その場では未読ドットで新着を見分けられる)
- お知らせ本文をクリックすると、`link` の種類に応じて該当画面へジャンプ(`kind: 'classroom'` → そのクラスを選択して課題詳細へ)。未知の kind は無視(前方互換)
- 60 秒間隔でポーリング。取得エラーはクラス管理本体に影響させない(表示しない)
- 送信側は Admin SPA(クラス詳細の「先生へのお知らせ」フォーム → `POST /admin/notifications`)。`docs/admin/README.md` を参照

| 要素 | data-testid | 操作 |
|------|-------------|------|
| 🔔 ボタン | `classroom-notifications-button` | クリックでパネル開閉 |
| 未読バッジ | `classroom-notifications-badge` | 未読数(10 以上は「9+」)。未読 0 で非表示 |
| 一覧パネル | `classroom-notifications-panel` | — |
| お知らせ 1 件 | `classroom-notification-item-{notificationId}` | クリックでリンク先へジャンプ + パネルを閉じる |
| 未読ドット | `classroom-notification-unread-dot` | 未読アイテムのみ |
| 空メッセージ | `classroom-notifications-empty` | お知らせ 0 件のとき |

## 4. 先生: クラス詳細 (`teacher-detail`)

クラスの参加状況と提出を管理する画面。モーダルが**ワイド表示 (968px)** に広がります。
Expand Down
106 changes: 106 additions & 0 deletions infra/smalruby-admin/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { OAuth2Client } from 'google-auth-library';
import { randomUUID } from 'crypto';
import {
buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot,
} from './restore-plan';
Expand All @@ -50,6 +51,13 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis
const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups';
const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions';
const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10);
// お知らせ (notification center, EPIC #1111): this stack is the single
// writer; teachers read through the classroom API. Notices are ephemeral
// guidance, so the writer stamps a TTL (default 180 days).
const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications';
const NOTIFICATION_TTL_DAYS = parseInt(process.env.NOTIFICATION_TTL_DAYS || '180', 10);
const MAX_NOTIFICATION_TITLE_LENGTH = 100;
const MAX_NOTIFICATION_MESSAGE_LENGTH = 1000;

const dynamoClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(dynamoClient);
Expand Down Expand Up @@ -495,6 +503,101 @@ async function handleSetClassroomStatus(
};
}

// --- お知らせ (notification center, EPIC #1111) ---

/**
* Notification link targets the teacher UI knows how to open. Kept as a
* whitelist so a typo'd kind can never be stored (the editor ignores
* unknown kinds, but the audit trail should stay clean).
*/
const NOTIFICATION_LINK_KINDS = new Set(['classroom']);

/**
* Write one notice into the teacher's inbox (single-writer: only this stack
* ever creates rows; the classroom API lists/marks-read them).
* @param teacherSub - recipient (resolved internally, never sent by the SPA)
* @param fields - type/title/body/link + the acting admin's email
* @returns the created notificationId
*/
async function putNotification(
teacherSub: string,
fields: {
type: string;
title: string;
body: string;
link: Record<string, unknown> | null;
createdBy: string;
},
): Promise<string> {
// Validate here (not at each call site) so every future send path — e.g.
// the recommendation notices of #1110/#1106 — goes through the whitelist.
if (fields.link && !NOTIFICATION_LINK_KINDS.has(String(fields.link.kind))) {
throw new ValidationError('Unsupported link kind');
}
const createdAt = new Date().toISOString();
// createdAt-prefixed sort key → the inbox Query returns newest first.
const notificationId = `${createdAt}#${randomUUID()}`;
await docClient.send(new PutCommand({
TableName: NOTIFICATIONS_TABLE,
Item: {
teacherSub,
notificationId,
type: fields.type,
title: fields.title,
body: fields.body,
...(fields.link ? { link: fields.link } : {}),
createdBy: fields.createdBy,
createdAt,
ttl: Math.floor(Date.now() / 1000) + NOTIFICATION_TTL_DAYS * 24 * 60 * 60,
},
}));
return notificationId;
}

/**
* POST /admin/notifications — send a notice to the teacher who owns the
* given classroom. The SPA sends a classroomId, never a teacherSub: subs
* stay internal (same principle as authorSub in the shared projections).
*/
async function handleSendNotification(
identity: AdminIdentity, body: Record<string, unknown>,
): Promise<APIGatewayProxyStructuredResultV2> {
const classroomId = typeof body.classroomId === 'string' ? body.classroomId.trim() : '';
if (!classroomId) {
throw new ValidationError('classroomId is required');
}
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title || title.length > MAX_NOTIFICATION_TITLE_LENGTH) {
throw new ValidationError(`title is required (at most ${MAX_NOTIFICATION_TITLE_LENGTH} characters)`);
}
const message = typeof body.message === 'string' ? body.message.trim() : '';
if (!message || message.length > MAX_NOTIFICATION_MESSAGE_LENGTH) {
throw new ValidationError(`message is required (at most ${MAX_NOTIFICATION_MESSAGE_LENGTH} characters)`);
}

const result = await docClient.send(new GetCommand({
TableName: CLASSROOMS_TABLE,
Key: { classroomId },
}));
const teacherSub = result.Item && typeof result.Item.teacherSub === 'string'
? result.Item.teacherSub
: '';
if (!teacherSub) {
throw new NotFoundError('Classroom not found');
}

const notificationId = await putNotification(teacherSub, {
type: 'admin_message',
title,
body: message,
link: { kind: 'classroom', classroomId },
createdBy: identity.email,
});
audit('notification.send', identity, { classroomId, type: 'admin_message' });

return { statusCode: 201, body: JSON.stringify({ notificationId }) };
}

// --- ddb-archive snapshot helpers ---

async function readSnapshot(key: string): Promise<Snapshot | null> {
Expand Down Expand Up @@ -775,6 +878,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
const classroomId = event.pathParameters?.classroomId || '';
result = await handleSetClassroomStatus(identity, classroomId, body);

} else if (method === 'POST' && path === '/admin/notifications') {
result = await handleSendNotification(identity, body);

} else {
throw new NotFoundError('Not found');
}
Expand Down
Loading
Loading