diff --git a/.claude/rules/infra/smalruby-classroom.md b/.claude/rules/infra/smalruby-classroom.md index 9bd5785cd69..313907afbfd 100644 --- a/.claude/rules/infra/smalruby-classroom.md +++ b/.claude/rules/infra/smalruby-classroom.md @@ -27,12 +27,16 @@ CDK project for the Classroom service (API Gateway + Lambda + DynamoDB + S3). `X-Google-Access-Token` を含む)。stage スロットリングに加え、`/classrooms/join` と `/classrooms/lookup` はルート個別に厳しめのスロットリングを設定 - **Lambda (Node.js 22)**: 単一ハンドラー (`lambda/handler.ts`) に全ビジネスロジック -- **DynamoDB (4 テーブル)**: +- **DynamoDB (8 テーブル)**: - `Classrooms{suffix}` — GSI `joinCode-index` / `teacherSub-index` - `ClassroomMemberships{suffix}` — GSI `sessionToken-index` - `ClassroomSubmissions{suffix}` — GSI `classroomId-memberId-index` - `ClassroomKickRequests{suffix}` — GSI `classroomId-seatNumber-index`(退室依頼 #692) - - すべて TTL `ttl`・**RemovalPolicy: DESTROY** + - `ClassroomGroups{suffix}` — GSI `teacherSub-index`(クラス=学級、長期 TTL 400日) + - `SharedAssignments{suffix}` — GSI `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(みんなの課題 #1066。**TTL なし・prod RETAIN + PITR**) + - `SharedAssignmentReports{suffix}` — 通報(TTL 90日) + - `ClassroomNotifications{suffix}` — お知らせ #1111(PK teacherSub / SK notificationId。**書き手は admin スタックのみ**・この Lambda は Query/UpdateItem だけ grant) + - 上記注記のないものは TTL `ttl`・**RemovalPolicy: DESTROY** - **S3** `smalruby-classroom-submissions{suffix}`: 提出ファイル (project.sb3, thumbnail.png, screenshots)。lifecycle 期限 = `CLASSROOM_TTL_DAYS` - **認証**: Google / Microsoft ID Token(`iss` 自動判別 + JWKS 検証。bug-report と同型)。 diff --git a/docs/admin/README.md b/docs/admin/README.md index 42ac0624d66..4f05afd13a6 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -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 に貼り付けられる ## 認証・認可モデル(要点) @@ -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) | diff --git a/docs/admin/screenshots/0106-classroom-notify.png b/docs/admin/screenshots/0106-classroom-notify.png new file mode 100644 index 00000000000..37d13980c71 Binary files /dev/null and b/docs/admin/screenshots/0106-classroom-notify.png differ diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index 7e0bfe417e9..d6d1d8d6a69 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -130,6 +130,7 @@ sequenceDiagram | **DynamoDB** | `ClassroomGroups-{stage}` | クラス(学級)情報(Streams: OLD_IMAGE) | | **DynamoDB** | `SharedAssignments-{stage}` | みんなの課題(TTL なし・prod RETAIN + PITR) | | **DynamoDB** | `SharedAssignmentReports-{stage}` | みんなの課題の通報(TTL 90日) | +| **DynamoDB** | `ClassroomNotifications-{stage}` | お知らせ(EPIC #1111。TTL は書き手の admin スタックが付与・既定 180 日) | | **S3** | `smalruby-shared-assignments-{stage}` | 共有課題のスナップショット(lifecycle なし = 永続・prod RETAIN) | | **S3** | `smalruby-classroom-submissions-{stage}` | 提出ファイル (.sb3, サムネイル, スクリーンショット) + `ddb-archive/` スナップショット。lifecycle = `ARCHIVE_RETENTION_DAYS`(既定 365 日) | | **Route53** | A レコード | カスタムドメイン | @@ -187,6 +188,8 @@ sequenceDiagram | `PATCH` | `/classroom-groups/{groupId}/topics` | トピックの add / remove / rename(rename・remove は課題へ一括追従) | | `POST` | `/classrooms/{id}/duplicate` | クラス(授業)の複製。課題コンテンツの S3 オブジェクトもコピー。`groupId` / `className` / `assignmentName` を上書き可。メンバー・提出は複製しない | | `POST` | `/classrooms/{id}/evaluate` | AI 評価支援。静的解析結果(シグナル + 擬似コード)を Anthropic API にリレーし、`mode: grade` は S/A/B/C 案 + 根拠 + needsReview、`mode: comment` は生徒向けポジティブコメント下書きを返す。1リクエスト最大10提出(API GW の30秒制限対策、クライアントがチャンク分割)。先生ごとにレート制限(既定 60回/時) | +| `GET` | `/notifications` | お知らせ一覧(EPIC #1111)。自分宛て(teacherSub)の直近 50 件 + `unreadCount` を返す。書き込みは admin スタックのみ(単一ライター) | +| `POST` | `/notifications/mark-read` | お知らせ既読化。`{notificationIds?}` — 省略時は一覧窓(直近 50 件)の未読を全既読化(パネルを開いたらバッジが消える UX) | ### みんなの課題 (共有課題ライブラリ・先生 ID Token 認証) @@ -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 diff --git a/docs/classroom/screenshots/0218-teacher-notifications.png b/docs/classroom/screenshots/0218-teacher-notifications.png new file mode 100644 index 00000000000..78bed2a5825 Binary files /dev/null and b/docs/classroom/screenshots/0218-teacher-notifications.png differ diff --git a/docs/classroom/source-code.md b/docs/classroom/source-code.md index 4f876da164b..4d532db729d 100644 --- a/docs/classroom/source-code.md +++ b/docs/classroom/source-code.md @@ -40,6 +40,7 @@ smalruby3-editor/ │ │ ├── containers/ │ │ │ ├── classroom-modal.jsx ← コンテナ(生徒/先生モードの振り分け) │ │ │ ├── use-teacher-classroom.js ← 先生用フック統合(他フックを束ねる) +│ │ │ ├── use-teacher-notifications.js ← お知らせセンター (EPIC #1111) │ │ │ ├── use-teacher-auth.js ← 認証フック(Google/Microsoft 共通) │ │ │ ├── use-teacher-classrooms.js ← クラス CRUD + 自動リフレッシュ │ │ │ ├── use-teacher-submissions.js ← 提出管理・一括ダウンロード @@ -109,6 +110,8 @@ CDK スタック定義。以下のリソースを作成: | `handleUpdateSubmission()` | 提出更新 (返却・コメント) | | `handleSetAssignment()` | 課題コンテンツ設定 (ページ + スターター、Presigned URL 生成) | | `handleGetAssignment()` | 課題コンテンツ取得 (生徒 Session Token / 先生 ID Token 両対応) | +| `handleListNotifications()` | お知らせ一覧 + 未読数 (EPIC #1111) | +| `handleMarkNotificationsRead()` | お知らせ既読化 (ids 省略で全既読) | | `handleCreateGroup()` / `handleListGroups()` / `handleUpdateGroup()` | 組(グループ)の作成・一覧・更新/アーカイブ | | `handleDuplicateClassroom()` | クラス複製(課題の S3 オブジェクトコピー含む) | | `handleListGoogleCourses()` | Google Classroom コース一覧 | @@ -180,6 +183,7 @@ GOOGLE_ID_TOKEN=eyJ... docker compose run --rm -w /app/infra/smalruby-classroom | `teacher-login-phase.jsx` | ログイン画面(Google / Microsoft ボタン + カルーセル) | | `teacher-sidebar.jsx` | サイドバー(クラス一覧ナビゲーション) | | `teacher-google-courses-phase.jsx` | Google Classroom コースインポート画面 | +| `teacher-notifications.jsx` | お知らせセンター(🔔 + 未読バッジ + 一覧パネル、EPIC #1111) | ### `packages/scratch-gui/src/lib/classroom-api.js` diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index 94b5f2867a7..b9cd3393203 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -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)** に広がります。 diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index ff70063aa4c..8ea115a1fad 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -27,6 +27,7 @@ import { import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { OAuth2Client } from 'google-auth-library'; +import { randomUUID } from 'crypto'; import { buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot, } from './restore-plan'; @@ -50,6 +51,13 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups'; const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions'; const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10); +// お知らせ (notification center, EPIC #1111): this stack is the single +// writer; teachers read through the classroom API. Notices are ephemeral +// guidance, so the writer stamps a TTL (default 180 days). +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; +const NOTIFICATION_TTL_DAYS = parseInt(process.env.NOTIFICATION_TTL_DAYS || '180', 10); +const MAX_NOTIFICATION_TITLE_LENGTH = 100; +const MAX_NOTIFICATION_MESSAGE_LENGTH = 1000; const dynamoClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(dynamoClient); @@ -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 | null; + createdBy: string; + }, +): Promise { + // Validate here (not at each call site) so every future send path — e.g. + // the recommendation notices of #1110/#1106 — goes through the whitelist. + if (fields.link && !NOTIFICATION_LINK_KINDS.has(String(fields.link.kind))) { + throw new ValidationError('Unsupported link kind'); + } + const createdAt = new Date().toISOString(); + // createdAt-prefixed sort key → the inbox Query returns newest first. + const notificationId = `${createdAt}#${randomUUID()}`; + await docClient.send(new PutCommand({ + TableName: NOTIFICATIONS_TABLE, + Item: { + teacherSub, + notificationId, + type: fields.type, + title: fields.title, + body: fields.body, + ...(fields.link ? { link: fields.link } : {}), + createdBy: fields.createdBy, + createdAt, + ttl: Math.floor(Date.now() / 1000) + NOTIFICATION_TTL_DAYS * 24 * 60 * 60, + }, + })); + return notificationId; +} + +/** + * POST /admin/notifications — send a notice to the teacher who owns the + * given classroom. The SPA sends a classroomId, never a teacherSub: subs + * stay internal (same principle as authorSub in the shared projections). + */ +async function handleSendNotification( + identity: AdminIdentity, body: Record, +): Promise { + const classroomId = typeof body.classroomId === 'string' ? body.classroomId.trim() : ''; + if (!classroomId) { + throw new ValidationError('classroomId is required'); + } + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title || title.length > MAX_NOTIFICATION_TITLE_LENGTH) { + throw new ValidationError(`title is required (at most ${MAX_NOTIFICATION_TITLE_LENGTH} characters)`); + } + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (!message || message.length > MAX_NOTIFICATION_MESSAGE_LENGTH) { + throw new ValidationError(`message is required (at most ${MAX_NOTIFICATION_MESSAGE_LENGTH} characters)`); + } + + const result = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + const teacherSub = result.Item && typeof result.Item.teacherSub === 'string' + ? result.Item.teacherSub + : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + + const notificationId = await putNotification(teacherSub, { + type: 'admin_message', + title, + body: message, + link: { kind: 'classroom', classroomId }, + createdBy: identity.email, + }); + audit('notification.send', identity, { classroomId, type: 'admin_message' }); + + return { statusCode: 201, body: JSON.stringify({ notificationId }) }; +} + // --- ddb-archive snapshot helpers --- async function readSnapshot(key: string): Promise { @@ -775,6 +878,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, token?: string, body?: unknown) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'https://smalruby.app', + }, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +describe('POST /admin/notifications (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + /** + * Route DynamoDB commands: the allowlist Get (SmalrubyAdmins), the + * classroom Get, and the notification Put. + * @param classroom - the Classrooms item returned for the lookup (null = missing) + * @returns collected PutCommand inputs + */ + const wireMocks = (classroom: Record | null) => { + const puts: Array> = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('Classrooms')) { + return { Item: classroom }; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return puts; + }; + + test('writes a notice addressed to the classroom owner, 201', async () => { + const puts = wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', + title: '運営からのお知らせ', + message: 'この課題、みんなの課題に共有しませんか?', + })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body as string).notificationId).toBeTruthy(); + expect(puts).toHaveLength(1); + const item = puts[0].Item as Record; + expect(item.teacherSub).toBe('teacher-sub-9'); + expect(item.type).toBe('admin_message'); + expect(item.title).toBe('運営からのお知らせ'); + expect(item.body).toBe('この課題、みんなの課題に共有しませんか?'); + expect(item.link).toEqual({ kind: 'classroom', classroomId: 'c1' }); + expect(item.createdBy).toBe('dev-admin@example.com'); + // createdAt-prefixed sort key (chronological inbox) + TTL stamped. + expect(String(item.notificationId)).toMatch(/^\d{4}-\d{2}-\d{2}T.*#[0-9a-f-]+$/); + expect(typeof item.ttl).toBe('number'); + }); + + test('404 when the classroom does not exist (nothing written)', async () => { + const puts = wireMocks(null); + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'missing', + title: 't', + message: 'm', + })); + expect(res.statusCode).toBe(404); + expect(puts).toHaveLength(0); + }); + + test('400 on missing/oversized fields', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const cases = [ + { title: 't', message: 'm' }, // no classroomId + { classroomId: 'c1', message: 'm' }, // no title + { classroomId: 'c1', title: 't' }, // no message + { classroomId: 'c1', title: 'x'.repeat(101), message: 'm' }, + { classroomId: 'c1', title: 't', message: 'x'.repeat(1001) }, + ]; + for (const body of cases) { + const res = await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, body)); + expect(res.statusCode).toBe(400); + } + }); + + test('401 without a token, 403 for a non-admin', async () => { + let res = await handler(makeEvent('POST', '/admin/notifications', undefined, {})); + expect(res.statusCode).toBe(401); + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ + sub: 'stranger', email: 'stranger@example.com', email_verified: true, name: null, + }), + }); + mockSend.mockResolvedValue({}); // no allowlist row + res = await handler(makeEvent('POST', '/admin/notifications', 'stranger-token', { + classroomId: 'c1', title: 't', message: 'm', + })); + expect(res.statusCode).toBe(403); + }); + + test('audit log records the send', async () => { + wireMocks({ classroomId: 'c1', teacherSub: 'teacher-sub-9' }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await handler(makeEvent('POST', '/admin/notifications', DEV_TOKEN, { + classroomId: 'c1', title: 't', message: 'm', + })); + const auditLines = logSpy.mock.calls + .map(args => String(args[0])) + .filter(line => line.includes('"audit":true')); + expect(auditLines.some(line => line.includes('"action":"notification.send"'))).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 1253ba5d4a7..33e39bec4ac 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -111,6 +111,8 @@ export class SmalrubyAdminStack extends cdk.Stack { SUBMISSIONS_TABLE_NAME: `ClassroomSubmissions${stageSuffix}`, GROUPS_TABLE_NAME: `ClassroomGroups${stageSuffix}`, SUBMISSIONS_BUCKET_NAME: `smalruby-classroom-submissions${stageSuffix}`, + NOTIFICATIONS_TABLE_NAME: `ClassroomNotifications${stageSuffix}`, + NOTIFICATION_TTL_DAYS: process.env.NOTIFICATION_TTL_DAYS || '180', STAGE: stage, }, bundling: { @@ -176,6 +178,14 @@ export class SmalrubyAdminStack extends cdk.Stack { groupsTable.grantReadWriteData(handlerFn); submissionsBucket.grantRead(handlerFn); + // お知らせ (notification center, EPIC #1111): this stack is the single + // WRITER of teacher notices; reads happen through the classroom API with + // the teacher's own auth. Write-only grant keeps that asymmetry honest. + const notificationsTable = dynamodb.Table.fromTableAttributes(this, 'NotificationsRef', { + tableName: `ClassroomNotifications${stageSuffix}`, + }); + notificationsTable.grantWriteData(handlerFn); + // --- Custom Domain --- const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; @@ -274,6 +284,9 @@ export class SmalrubyAdminStack extends cdk.Stack { addRoute('/admin/classrooms/{classroomId}/restore-plan', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/{classroomId}/restore', [apigatewayv2.HttpMethod.POST]); + // お知らせ送信 (notification center #1111) + addRoute('/admin/notifications', [apigatewayv2.HttpMethod.POST]); + // Single-operator tool: a human never needs more than a couple of // requests per second, so throttle hard to cap the API-GW request bill an // attacker can run up (the JWT authorizer already caps Lambda/log cost). diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index f26f1bc1a91..0452a8a47e1 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -34,6 +34,9 @@ const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-clas // みんなの課題 — nationwide shared assignment library (EPIC #1066) const SHARED_ASSIGNMENTS_TABLE = process.env.SHARED_ASSIGNMENTS_TABLE_NAME || 'SharedAssignments'; const SHARED_REPORTS_TABLE = process.env.SHARED_REPORTS_TABLE_NAME || 'SharedAssignmentReports'; +// お知らせ (notification center, EPIC #1111) — admin → teacher notices. The +// admin stack writes items; this API lists/marks-read for the teacher. +const NOTIFICATIONS_TABLE = process.env.NOTIFICATIONS_TABLE_NAME || 'ClassroomNotifications'; const SHARED_BUCKET = process.env.SHARED_BUCKET_NAME || 'smalruby-shared-assignments'; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || ''; @@ -100,6 +103,9 @@ const MAX_SUPPLEMENT_URL_LENGTH = 500; const MAX_AUTHOR_NAME_LENGTH = 30; const MAX_AUTHOR_AFFILIATION_LENGTH = 50; const MAX_SHARED_REPORT_REASON_LENGTH = 200; +// お知らせの一覧窓(= mark-read が既読化する範囲)。通知は短命な案内なので +// 直近 50 件で足りる想定(TTL は書き手の admin スタックが付ける)。 +const NOTIFICATION_LIST_LIMIT = 50; // Class (旧組) v2 data model: every assignment (Classrooms record) belongs to // a class (ClassroomGroups record), and class-level GC linkage / co-teachers / @@ -4328,6 +4334,98 @@ async function handleReportSharedAssignment( return { statusCode: 201, body: JSON.stringify({}) }; } +// --- お知らせ (notification center, EPIC #1111) --- +// Items are written by the admin stack with this shape: +// { teacherSub, notificationId (createdAt-prefixed for chronological SK), +// type, title, body, link?, createdBy, createdAt, ttl } +// This API is deliberately read-only for teachers (list + mark-read): the +// single writer stays on the admin side, so notices cannot be forged from +// the editor. + +async function handleListNotifications( + identity: TeacherIdentity, +): Promise { + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + // notificationId starts with the ISO createdAt → newest first. + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + const notifications = (result.Items || []).map(item => ({ + notificationId: item.notificationId, + type: item.type || 'admin_message', + title: item.title, + body: item.body || null, + link: item.link || null, + readAt: item.readAt || null, + createdAt: item.createdAt, + })); + const unreadCount = notifications.filter(n => !n.readAt).length; + return { statusCode: 200, body: JSON.stringify({ notifications, unreadCount }) }; +} + +async function handleMarkNotificationsRead( + identity: TeacherIdentity, + body: Record, +): Promise { + // A top-level JSON array would read `body.notificationIds` as undefined and + // silently take the mark-all path — reject it explicitly (review finding). + if (Array.isArray(body)) { + throw new ValidationError('Request body must be a JSON object'); + } + let ids: string[]; + if (body.notificationIds === undefined || body.notificationIds === null) { + // No explicit ids = mark everything currently unread, bounded by the + // same window the list endpoint shows, so "open panel → clear badge" + // stays consistent with what the teacher actually saw. + const result = await docClient.send(new QueryCommand({ + TableName: NOTIFICATIONS_TABLE, + KeyConditionExpression: 'teacherSub = :sub', + ExpressionAttributeValues: { ':sub': identity.sub }, + ScanIndexForward: false, + Limit: NOTIFICATION_LIST_LIMIT, + })); + ids = (result.Items || []) + .filter(item => !item.readAt) + .map(item => String(item.notificationId)); + } else if ( + Array.isArray(body.notificationIds) && + body.notificationIds.length <= NOTIFICATION_LIST_LIMIT && + // Length-bound each id so a garbage id fails as 400, not as a DynamoDB + // ValidationException → 500 (real ids are ~61 chars: ISO + '#' + UUID). + body.notificationIds.every(id => typeof id === 'string' && id.length <= 200) + ) { + ids = body.notificationIds as string[]; + } else { + throw new ValidationError( + `notificationIds must be an array of at most ${NOTIFICATION_LIST_LIMIT} strings`, + ); + } + + const now = new Date().toISOString(); + let updated = 0; + for (const notificationId of ids) { + try { + await docClient.send(new UpdateCommand({ + TableName: NOTIFICATIONS_TABLE, + // The key includes the caller's own teacherSub, so a teacher can + // never touch another teacher's rows regardless of the ids sent. + Key: { teacherSub: identity.sub, notificationId }, + UpdateExpression: 'SET readAt = if_not_exists(readAt, :now)', + // Never create phantom rows for ids that don't exist (TTL races). + ConditionExpression: 'attribute_exists(notificationId)', + ExpressionAttributeValues: { ':now': now }, + })); + updated += 1; + } catch (err) { + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + } + return { statusCode: 200, body: JSON.stringify({ updated }) }; +} + // --- Main handler --- export const handler = async (event: APIGatewayProxyEventV2): Promise => { @@ -4379,6 +4477,17 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +interface MakeEventOptions { + body?: unknown; + token?: string; +} + +const makeEvent = ( + method: string, + path: string, + { body, token }: MakeEventOptions = {}, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + origin: 'http://localhost:8601', + }, + pathParameters: {}, + queryStringParameters: undefined, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const notice = (id: string, extra: Record = {}) => ({ + teacherSub: 'dev-test-teacher', + notificationId: id, + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + createdBy: 'admin@example.com', + createdAt: id.slice(0, 24), + ...extra, +}); + +describe('お知らせセンター (EPIC #1111)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + describe('GET /notifications', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('GET', '/notifications')); + expect(res.statusCode).toBe(401); + }); + + test('returns the caller-scoped inbox with unreadCount', async () => { + mockSend.mockImplementation(async (command) => { + expect(command.constructor.name).toBe('QueryCommand'); + expect(command.input.ExpressionAttributeValues[':sub']).toBe('dev-test-teacher'); + expect(command.input.ScanIndexForward).toBe(false); + return { + Items: [ + notice('2026-07-25T01:00:00.000Z#b'), + notice('2026-07-24T01:00:00.000Z#a', { readAt: '2026-07-24T02:00:00.000Z' }), + ], + }; + }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body || '{}'); + expect(data.unreadCount).toBe(1); + expect(data.notifications).toHaveLength(2); + expect(data.notifications[0]).toEqual({ + notificationId: '2026-07-25T01:00:00.000Z#b', + type: 'admin_message', + title: 'お知らせタイトル', + body: 'お知らせ本文', + link: { kind: 'classroom', classroomId: 'c1' }, + readAt: null, + createdAt: '2026-07-25T01:00:00.000Z', + }); + // Internal fields never leak to the editor. + expect(res.body).not.toContain('createdBy'); + expect(res.body).not.toContain('teacherSub'); + }); + + test('empty inbox returns zero unread', async () => { + mockSend.mockResolvedValue({ Items: [] }); + const res = await handler(makeEvent('GET', '/notifications', { token: DEV_TOKEN })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ notifications: [], unreadCount: 0 }); + }); + }); + + describe('POST /notifications/mark-read', () => { + test('401 without a token', async () => { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { body: {} })); + expect(res.statusCode).toBe(401); + }); + + test('marks the given ids read, keyed to the caller', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['n1', 'n2'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates).toHaveLength(2); + expect(updates[0].Key).toEqual({ teacherSub: 'dev-test-teacher', notificationId: 'n1' }); + // Never create phantom rows; never overwrite an earlier readAt. + expect(updates[0].ConditionExpression).toContain('attribute_exists'); + expect(updates[0].UpdateExpression).toContain('if_not_exists(readAt'); + }); + + test('without ids marks everything currently unread', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'QueryCommand') { + return { + Items: [ + notice('n3'), + notice('n2', { readAt: '2026-07-24T02:00:00.000Z' }), + notice('n1'), + ], + }; + } + updates.push(command.input); + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: {}, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 2 }); + expect(updates.map(u => (u.Key as Record).notificationId)).toEqual(['n3', 'n1']); + }); + + test('ids that vanished (TTL race) are skipped, not errors', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'UpdateCommand') { + const err = new Error('conditional failed') as Error & { name: string }; + err.name = 'ConditionalCheckFailedException'; + throw err; + } + return {}; + }); + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: ['gone'] }, + })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body || '{}')).toEqual({ updated: 0 }); + }); + + test('400 on malformed notificationIds', async () => { + const bads = [ + 'x', + [1, 2], + Array.from({ length: 51 }, (_, i) => `n${i}`), + ['x'.repeat(201)], // overlong id → 400, not a DynamoDB 500 + ]; + for (const bad of bads) { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: { notificationIds: bad }, + })); + expect(res.statusCode).toBe(400); + } + }); + + test('400 when the whole body is a JSON array (would silently mark all)', async () => { + const res = await handler(makeEvent('POST', '/notifications/mark-read', { + token: DEV_TOKEN, + body: ['n1', 'n2'], + })); + expect(res.statusCode).toBe(400); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 15e7f7f3913..7c5a8aaedaa 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -21,6 +21,7 @@ export class ClassroomStack extends cdk.Stack { public readonly groupsTable: dynamodb.Table; public readonly sharedAssignmentsTable: dynamodb.Table; public readonly sharedReportsTable: dynamodb.Table; + public readonly notificationsTable: dynamodb.Table; public readonly submissionsBucket: s3.Bucket; public readonly sharedBucket: s3.Bucket; public readonly api: apigatewayv2.HttpApi; @@ -332,6 +333,35 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.sharedReportsTable).add('ResourceType', 'DynamoDB'); + // --- お知らせ (notification center, EPIC #1111) --- + // Admin → teacher notices surfaced in the class management UI. This stack + // owns the table because teachers read it through this API with their + // existing ID-token auth; the admin stack imports it by the fleet's stage + // naming convention and only writes (mirror of the SharedAssignments + // arrangement — N2: the admin stack never modifies this stack). + // PK teacherSub / SK notificationId (createdAt-prefixed → chronological), + // so a single Query serves the per-teacher inbox. Notices are ephemeral + // guidance, not records: TTL is set by the writer (admin stack). + this.notificationsTable = new dynamodb.Table(this, 'NotificationsTable', { + tableName: `ClassroomNotifications${stageSuffix}`, + partitionKey: { + name: 'teacherSub', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'notificationId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: false, + }, + timeToLiveAttribute: 'ttl', + }); + + cdk.Tags.of(this.notificationsTable).add('ResourceType', 'DynamoDB'); + // --- S3 Bucket for submissions --- this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', { @@ -407,6 +437,7 @@ export class ClassroomStack extends cdk.Stack { GROUPS_TABLE_NAME: this.groupsTable.tableName, SHARED_ASSIGNMENTS_TABLE_NAME: this.sharedAssignmentsTable.tableName, SHARED_REPORTS_TABLE_NAME: this.sharedReportsTable.tableName, + NOTIFICATIONS_TABLE_NAME: this.notificationsTable.tableName, SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName, SHARED_BUCKET_NAME: this.sharedBucket.bucketName, SHARE_DAILY_LIMIT: process.env.SHARE_DAILY_LIMIT || '10', @@ -445,6 +476,11 @@ export class ClassroomStack extends cdk.Stack { this.groupsTable.grantReadWriteData(handlerFn); this.sharedAssignmentsTable.grantReadWriteData(handlerFn); this.sharedReportsTable.grantReadWriteData(handlerFn); + // お知らせは admin スタックが単一の書き手 (#1111)。この Lambda は一覧 + // (Query) と既読化 (UpdateItem) しか行わないので、PutItem を含む RW では + // なく必要最小限だけ grant して「エディタからお知らせを偽造できない」を + // IAM でも担保する(レビュー指摘)。 + this.notificationsTable.grant(handlerFn, 'dynamodb:Query', 'dynamodb:UpdateItem'); this.submissionsBucket.grantPut(handlerFn); this.submissionsBucket.grantRead(handlerFn); this.sharedBucket.grantPut(handlerFn); @@ -692,6 +728,21 @@ export class ClassroomStack extends cdk.Stack { integration, }); + // お知らせ (notification center #1111) — teacher-facing inbox. Own root + // path; writes happen from the admin stack, so this API only lists and + // marks-read for the authenticated teacher. + this.api.addRoutes({ + path: '/notifications', + methods: [apigatewayv2.HttpMethod.GET], + integration, + }); + + this.api.addRoutes({ + path: '/notifications/mark-read', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + // Groups (組) — teacher-side organizing concept. Own root path so the // /classrooms/{classroomId} patterns never shadow it. this.api.addRoutes({ diff --git a/packages/admin/src/components/app.css b/packages/admin/src/components/app.css index 988e2902790..b29a6c7dd98 100644 --- a/packages/admin/src/components/app.css +++ b/packages/admin/src/components/app.css @@ -155,6 +155,27 @@ body { margin-top: 0.75rem; } +/* Bordered sub-section inside a detail view (お知らせ送信 #1111 など). */ +.admin-panel { + margin-top: 1.25rem; + padding: 0.75rem; + border: 1px solid hsl(220, 15%, 88%); + border-radius: 0.4rem; + background: white; +} + +.admin-panel input, +.admin-panel textarea { + display: block; + width: 100%; + max-width: 32rem; + margin: 0.4rem 0; + padding: 0.4rem; + border: 1px solid hsl(220, 15%, 75%); + border-radius: 0.4rem; + font: inherit; +} + .admin-actions button { padding: 0.4rem 0.9rem; border: 1px solid hsl(220, 15%, 75%); diff --git a/packages/admin/src/components/classrooms-view.jsx b/packages/admin/src/components/classrooms-view.jsx index b3c027da1ae..6ac49303aba 100644 --- a/packages/admin/src/components/classrooms-view.jsx +++ b/packages/admin/src/components/classrooms-view.jsx @@ -16,6 +16,7 @@ import { fetchClassrooms, fetchRestoreCandidates, fetchRestorePlan, + sendNotification, setClassroomStatus } from '../lib/admin-api.js'; import ClassroomOverviewView from './classroom-overview-view.jsx'; @@ -34,6 +35,107 @@ const formatDate = iso => (iso ? iso.replace('T', ' ').slice(0, 16) : '-'); const itemLine = (item, tail) => `課題: ${item.assignmentName || '-'} ・ コード: ${item.joinCode} ・ ${tail}`; +// お知らせ送信 (notification center #1111): この課題を作った先生の +// クラス管理画面右上「お知らせ」に届く。宛先はサーバー側で classroomId +// から解決される(teacherSub は SPA に出さない)。 +const NotificationSendPanel = ({classroomId}) => { + const [title, setTitle] = useState('運営からのお知らせ'); + const [message, setMessage] = useState(''); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + + const handleTitle = useCallback(e => setTitle(e.target.value), []); + const handleMessage = useCallback(e => setMessage(e.target.value), []); + const handleArm = useCallback(() => { + setSent(false); + setError(''); + setConfirming(true); + }, []); + const handleDisarm = useCallback(() => setConfirming(false), []); + const handleSend = useCallback(async () => { + setBusy(true); + setError(''); + try { + await sendNotification(classroomId, {title: title.trim(), message: message.trim()}); + setConfirming(false); + setSent(true); + setMessage(''); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }, [classroomId, title, message]); + + return ( +
+

{'先生へのお知らせ'}

+

+ {'この課題を作成した先生のクラス管理画面(右上「お知らせ」)に届きます。'} +

+ +