diff --git a/docs/admin/README.md b/docs/admin/README.md index 8f4c2943142..b240f5e89b3 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -17,7 +17,7 @@ ### セクション(3 ドメイン) 1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / **限定公開タブ**(推薦候補の母集団・#1110)/ 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)/ **推薦する・推薦を取り消す**(#1110。2 段階確認・audit。推薦すると作成した先生のお知らせセンター #1111 に通知が届き、限定公開を全体公開に広げる動線につながる。取り消しは通知なし) -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 で、この面が単一の書き手) +2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。候補行には**推奨済みバッジ**)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)。クラス詳細には**先生へのお知らせ送信**(EPIC #1111)があり、タイトル + 本文を二段階確認で `POST /admin/notifications` へ送ると、その課題を作成した先生のクラス管理画面右上「お知らせ」🔔 に届く(宛先 teacherSub はサーバー側で classroomId から解決し SPA には出さない。書き込み先 `ClassroomNotifications` テーブルは名前規約 import + write-only grant で、この面が単一の書き手)。さらに**みんなの課題への共有推奨**(EPIC #1106)があり、二段階確認で `POST /admin/classrooms/{id}/recommend-sharing` を実行すると先生へお知らせ(`share_suggestion`)が届き、先生の課題詳細に「この課題、みんなの課題に共有しませんか?」バナーが出る(公開は CC BY 同意を伴う先生本人の共有フローのみ — 代理公開はしない。取り消しは通知なし・audit のみ) 3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる ## 認証・認可モデル(要点) @@ -92,3 +92,4 @@ cd tools/playwright-verify && node verify-admin.mjs | `screenshots/0105-bug-reports.png` | バグ報告: 閲覧一覧 | | `screenshots/0106-classroom-notify.png` | クラス詳細: 先生へのお知らせ送信(EPIC #1111) | | `screenshots/0107-shared-recommend.png` | みんなの課題: 限定公開の詳細と推薦の確認(EPIC #1110) | +| `screenshots/0108-recommend-sharing.png` | クラス詳細: 共有推奨の確認(EPIC #1106) | diff --git a/docs/admin/screenshots/0108-recommend-sharing.png b/docs/admin/screenshots/0108-recommend-sharing.png new file mode 100644 index 00000000000..0c1fd913d12 Binary files /dev/null and b/docs/admin/screenshots/0108-recommend-sharing.png differ diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index a9d28361c91..03d3ee09c27 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -248,6 +248,8 @@ erDiagram string googleClassroomCourseId "任意" list coTeacherEmails "共同管理者の email 配列 (任意, 最大10)" map assignment "課題コンテンツ (任意): {pages: [{text, imageKey?}], starterKey?, updatedAt}" + string recommendedForSharingAt "共有推奨 (#1106, 任意)。書き込みは admin API のみ。先生側 API には boolean recommendedForSharing を投影" + string recommendedForSharingBy "推奨した admin の email (内部用・先生側 API では返さない)" string groupId "所属する組 (任意)" string status "active / archived" string createdAt "ISO8601" diff --git a/docs/classroom/screenshots/0219-share-suggestion-banner.png b/docs/classroom/screenshots/0219-share-suggestion-banner.png new file mode 100644 index 00000000000..04bbf6d3d43 Binary files /dev/null and b/docs/classroom/screenshots/0219-share-suggestion-banner.png differ diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index 4ef6ad23c6d..5b30962f009 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -280,6 +280,8 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `shared-detail-broaden` | button | 「みんなの課題に公開する」(自分の限定公開のみ・#1110) | | `shared-broaden-done` | p | 全体公開の完了メッセージ(#1110) | | `shared-import-success` | p | 取り込み完了メッセージ(ボード上) | +| `classroom-share-suggestion-banner` / `classroom-share-suggestion-open` | div/button | 共有推奨バナーと「共有フォームを開く」CTA(#1106) | +| `classroom-board-share-suggested-{classroomId}` | span | ボード行の「共有おすすめ」マーク(#1106) | | `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) | | `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク | | `classroom-board-create-name` / `classroom-board-create-submit` | input / button | インライン課題作成(課題名のみ) | diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index b9cd3393203..030cf58a5cb 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -185,6 +185,22 @@ Google または Microsoft アカウントでサインインする画面。先 | 未読ドット | `classroom-notification-unread-dot` | 未読アイテムのみ | | 空メッセージ | `classroom-notifications-empty` | お知らせ 0 件のとき | +## 1.9 先生: 共有推奨バナー(#1106) + +![共有推奨バナー](screenshots/0219-share-suggestion-banner.png) + +運営(Admin)が「みんなの課題に共有する価値がある」と判断した課題には、課題詳細の上部に「**この課題、みんなの課題に共有しませんか?**」バナーが出る。「共有フォームを開く」でボードの共有ステップ(既存の共有フロー)が開く。公開はあくまで CC BY 同意を伴う**先生本人の共有操作のみ**(運営による代理公開はしない)。 + +- 推奨と同時に運営からのお知らせ(🔔・`share_suggestion`)も届き、クリックでこの課題の詳細へジャンプする +- 課題一覧(ボード)の該当行には「**共有おすすめ**」マークが付く +- フラグは admin が取り消すまで表示される(先生側から消す操作は無い) + +| 要素 | data-testid | 操作 | +|------|-------------|------| +| バナー | `classroom-share-suggestion-banner` | — | +| 共有フォームを開く | `classroom-share-suggestion-open` | ボードへ戻って共有ステップを開く | +| ボード行のマーク | `classroom-board-share-suggested-{classroomId}` | — | + ## 4. 先生: クラス詳細 (`teacher-detail`) クラスの参加状況と提出を管理する画面。モーダルが**ワイド表示 (968px)** に広がります。 diff --git a/infra/smalruby-admin/lambda/classroom-overview.ts b/infra/smalruby-admin/lambda/classroom-overview.ts index a3b7894aaca..b99a0e0c34a 100644 --- a/infra/smalruby-admin/lambda/classroom-overview.ts +++ b/infra/smalruby-admin/lambda/classroom-overview.ts @@ -15,7 +15,12 @@ export interface ClassroomRow { teacherSub?: unknown; status?: unknown; // 'active' | 'archived' createdAt?: unknown; - content?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string }; + // 課題コンテンツは classroom item の `assignment` 属性(`content` は + // SharedAssignments 側のフィールド名 — 取り違えると richness が常に 0 で + // 有益候補が空になるバグだった #1106)。 + assignment?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string }; + // 共有推奨 (#1106): admin が立てるフラグ。候補一覧に「推奨済み」を出す。 + recommendedForSharingAt?: unknown; } /** Quota rows reuse the classrooms key space; never count them. */ @@ -33,10 +38,10 @@ export function isRealClassroom(row: ClassroomRow): boolean { export function richness(row: ClassroomRow): { score: number; pageCount: number; hasImages: boolean; hasStarter: boolean; } { - const pages = row.content?.pages || []; + const pages = row.assignment?.pages || []; const pageCount = pages.length; const hasImages = pages.some(p => typeof p?.imageKey === 'string' && p.imageKey.length > 0); - const hasStarter = typeof row.content?.starterKey === 'string' && row.content.starterKey.length > 0; + const hasStarter = typeof row.assignment?.starterKey === 'string' && row.assignment.starterKey.length > 0; let score = 0; if (pageCount >= 1) score += 1; if (pageCount >= 2) score += 1; @@ -78,6 +83,7 @@ export function buildOverview( classroomId: string; className: string; assignmentName: string; teacherSub: string; score: number; pageCount: number; hasImages: boolean; hasStarter: boolean; createdAt: string; likelyShared: boolean; + recommendedForSharing: boolean; }[]; themeKeywords: { keyword: string; count: number }[]; } { @@ -127,6 +133,7 @@ export function buildOverview( hasStarter: r.hasStarter, createdAt, likelyShared, + recommendedForSharing: !!row.recommendedForSharingAt, }); } } diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index d45bfa0a817..bdd4503532d 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -504,9 +504,110 @@ function mapClassroomForAdmin(item: Record) { updatedAt: item.updatedAt || null, restoredAt: item.restoredAt || null, expiresAt: item.ttl ? new Date((item.ttl as number) * 1000).toISOString() : null, + // 共有推奨 (#1106) + recommendedForSharing: !!item.recommendedForSharingAt, + recommendedForSharingAt: item.recommendedForSharingAt || null, + recommendedForSharingBy: item.recommendedForSharingBy || null, }; } +// --- 共有推奨 (EPIC #1106): 有益な課題を先生に「みんなの課題へ共有」促す --- + +/** + * POST/DELETE /admin/classrooms/{classroomId}/recommend-sharing — flag an + * assignment as "worth sharing to みんなの課題" (or withdraw the flag). + * Flagging notifies the owning teacher through the notification center + * (#1111); the teacher's own share flow (CC BY consent) stays the only + * publication path — admins never publish on a teacher's behalf. + */ +async function handleSetSharingRecommendation( + identity: AdminIdentity, classroomId: string, recommended: boolean, +): Promise { + const result = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + const item = result.Item as Record | undefined; + // Quota rows share the key space. + if (!item || String(classroomId).includes('-quota#')) { + throw new NotFoundError('Classroom not found'); + } + const teacherSub = typeof item.teacherSub === 'string' ? item.teacherSub : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + // アーカイブ済みは先生から見えないので新規の推奨はしない。ただし取り消し + // (下) は許す — 推奨後にアーカイブされるとフラグが運営から触れなくなり、 + // 復元時に古いバナーが再出現するため(レビュー指摘)。 + if (recommended && item.status !== 'active') { + throw new NotFoundError('Classroom not found'); + } + // 中身(説明ページ or スターター)が無い課題は共有 API が拒否するので、 + // 「共有しませんか?」と促しても行き止まりになる — 推奨自体を拒否する + // (レビュー指摘)。 + const assignment = item.assignment as { pages?: unknown[]; starterKey?: string } | undefined; + const hasAssignmentContent = !!assignment && + ((Array.isArray(assignment.pages) && assignment.pages.length > 0) || !!assignment.starterKey); + if (recommended && !hasAssignmentContent) { + throw new ValidationError('Classroom has no assignment content to share'); + } + + const alreadyRecommended = !!item.recommendedForSharingAt; + if (recommended && !alreadyRecommended) { + const now = new Date().toISOString(); + // #1110 と同じ「通知 → 印付け」の順 + 印付けの冪等を原子化。真に同時の + // POST では通知が重複しうる(SPA の busy 無効化で実質防止・最悪ケースは + // 重複通知で無害 — #1110 と同じ割り切り)。 + const title = String(item.assignmentName || item.className || '課題'); + await putNotification(teacherSub, { + type: 'share_suggestion', + title: 'この課題、みんなの課題に共有しませんか?', + body: `「${title}」が内容の充実した課題として運営のおすすめに選ばれました。課題一覧の「共有」から、全国の先生に共有できます。`, + link: { kind: 'classroom', classroomId }, + createdBy: identity.email, + }); + try { + await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + UpdateExpression: + 'SET recommendedForSharingAt = :now, recommendedForSharingBy = :email, updatedAt = :now', + ConditionExpression: 'attribute_not_exists(recommendedForSharingAt)', + ExpressionAttributeValues: { ':now': now, ':email': identity.email }, + })); + } catch (err) { + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + audit('classroom.recommendSharing', identity, { classroomId }); + return { + statusCode: 200, + body: JSON.stringify(mapClassroomForAdmin({ + ...item, recommendedForSharingAt: now, recommendedForSharingBy: identity.email, + })), + }; + } + + if (!recommended && alreadyRecommended) { + await docClient.send(new UpdateCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + UpdateExpression: 'REMOVE recommendedForSharingAt, recommendedForSharingBy SET updatedAt = :now', + ExpressionAttributeValues: { ':now': new Date().toISOString() }, + })); + // 取り消しは通知しない(先生を騒がせない)。audit で追跡できる。 + audit('classroom.unrecommendSharing', identity, { classroomId }); + return { + statusCode: 200, + body: JSON.stringify(mapClassroomForAdmin({ + ...item, recommendedForSharingAt: undefined, recommendedForSharingBy: undefined, + })), + }; + } + + // No-op (already in the requested state) — idempotent success. + return { statusCode: 200, body: JSON.stringify(mapClassroomForAdmin(item)) }; +} + async function handleListClassrooms( identity: AdminIdentity, query: Record, ): Promise { @@ -973,6 +1074,14 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { expect(o.candidates.some(c => c.classroomId === 'c3')).toBe(false); }); + test('候補に共有推奨フラグが載る (#1106)', () => { + expect(o.candidates.find(c => c.classroomId === 'c1')?.recommendedForSharing).toBe(true); + expect(o.candidates.find(c => c.classroomId === 'c2')?.recommendedForSharing).toBe(false); + }); + + test('classroom item の実フィールド名は assignment(content だと richness が 0 になる回帰)', () => { + expect(richness({ content: { pages: [{ text: 'a' }], starterKey: 's' } } as never).score).toBe(0); + }); + test('theme keywords tally repeated tokens only', () => { // '5年1組' appears on c1 and c3 expect(o.themeKeywords.some(k => k.keyword === '5年1組' && k.count === 2)).toBe(true); diff --git a/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts b/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts new file mode 100644 index 00000000000..519ad75d1a6 --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts @@ -0,0 +1,182 @@ +/** + * 共有推奨 API tests — EPIC #1106. + * + * Flagging an assignment as "worth sharing to みんなの課題" notifies the + * owning teacher (#1111) so they can share it themselves (CC BY consent + * stays with the author — admins never publish on a teacher's behalf). + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = (method: string, path: string, classroomId: string) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + pathParameters: { classroomId }, +}); + +const classroomItem = (over: Record = {}) => ({ + classroomId: 'c1', + className: '2年1組', + assignmentName: 'ねこ迷路ゲーム', + teacherSub: 'teacher-sub-9', + joinCode: 'abc234', + studentCount: 30, + assignment: { pages: [{ text: 'ページ1' }], starterKey: 'c1/assignment/starter.sb3' }, + status: 'active', + createdAt: '2026-07-10T00:00:00.000Z', + ...over, +}); + +describe('POST/DELETE /admin/classrooms/{id}/recommend-sharing (EPIC #1106)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + const wireMocks = (classroom: Record | null) => { + const updates: Array> = []; + const puts: Array> = []; + const order: string[] = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + order.push(name); + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('Classrooms')) { + return { Item: classroom }; + } + if (name === 'UpdateCommand') { + updates.push(command.input as Record); + return {}; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return { updates, puts, order }; + }; + + test('flags the assignment and notifies the owning teacher (通知→印付けの順)', async () => { + const { updates, puts, order } = wireMocks(classroomItem()); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + + expect(puts).toHaveLength(1); + const notice = puts[0].Item as Record; + expect(notice.teacherSub).toBe('teacher-sub-9'); + expect(notice.type).toBe('share_suggestion'); + expect(String(notice.title)).toContain('共有しませんか'); + expect(String(notice.body)).toContain('ねこ迷路ゲーム'); + expect(notice.link).toEqual({ kind: 'classroom', classroomId: 'c1' }); + + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('SET recommendedForSharingAt'); + expect(updates[0].ConditionExpression).toContain('attribute_not_exists(recommendedForSharingAt)'); + expect(order.indexOf('PutCommand')).toBeLessThan(order.indexOf('UpdateCommand')); + + const body = JSON.parse(res.body as string); + expect(body.recommendedForSharing).toBe(true); + // teacherSub never crosses to the SPA. + expect(res.body).not.toContain('teacher-sub-9'); + }); + + test('already-flagged is a no-op (no re-notify)', async () => { + const { updates, puts } = wireMocks(classroomItem({ + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal removes the flag silently', async () => { + const { updates, puts } = wireMocks(classroomItem({ + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('REMOVE recommendedForSharingAt, recommendedForSharingBy'); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommendedForSharing).toBe(false); + }); + + test('400 when the assignment has no content (共有の行き止まり防止)', async () => { + const { updates, puts } = wireMocks(classroomItem({ assignment: undefined })); + const res = await handler(makeEvent('POST', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(400); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal works even after the classroom was archived (復元時の古いバナー防止)', async () => { + const { updates, puts } = wireMocks(classroomItem({ + status: 'archived', + recommendedForSharingAt: '2026-07-20T00:00:00.000Z', + recommendedForSharingBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/classrooms/c1/recommend-sharing', 'c1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(puts).toHaveLength(0); + }); + + test('404 for missing / archived / quota-row classrooms', async () => { + for (const [item, id] of [ + [null, 'missing'], + [classroomItem({ status: 'archived' }), 'c1'], + [classroomItem({ classroomId: 'eval-quota#t#2026' }), 'eval-quota#t#2026'], + ] as const) { + wireMocks(item as Record | null); + const res = await handler(makeEvent( + 'POST', `/admin/classrooms/${id}/recommend-sharing`, String(id), + )); + expect(res.statusCode).toBe(404); + } + }); +}); diff --git a/infra/smalruby-admin/lambda/tests/handler.test.ts b/infra/smalruby-admin/lambda/tests/handler.test.ts index e2d5c539f2d..d9cf8f3fdfd 100644 --- a/infra/smalruby-admin/lambda/tests/handler.test.ts +++ b/infra/smalruby-admin/lambda/tests/handler.test.ts @@ -509,9 +509,11 @@ describe('クラス管理 + 期限切れ復元 (issue #1084)', () => { } if (name === 'ScanCommand') { return { Items: [ + // classroom item の課題コンテンツは `assignment` 属性(`content` は + // SharedAssignments 側の名前 — 取り違えの回帰防止 #1106)。 { classroomId: 'c1', className: '5年1組', assignmentName: 'ねこ迷路ゲーム', teacherSub: 't1', status: 'active', createdAt: '2026-07-10T00:00:00.000Z', - content: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' } }, + assignment: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' } }, { classroomId: 'eval-quota#t1#2026-07-19', status: 'active' }, ] }; } diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 541a4a9e98a..d28d53ef2cd 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -286,6 +286,9 @@ export class SmalrubyAdminStack extends cdk.Stack { [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]); addRoute('/admin/classrooms/{classroomId}/restore-plan', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/classrooms/{classroomId}/restore', [apigatewayv2.HttpMethod.POST]); + // 共有推奨 (#1106) + addRoute('/admin/classrooms/{classroomId}/recommend-sharing', + [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.DELETE]); // お知らせ送信 (notification center #1111) addRoute('/admin/notifications', [apigatewayv2.HttpMethod.POST]); diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index c4ea0689a9a..d6b0244e7d1 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -658,6 +658,9 @@ function mapClassroomSummary(item: Record, identity: TeacherIde topic: item.topic || null, sortDate: item.sortDate || item.createdAt || null, hasAssignment: hasAssignmentContent(item), + // 共有推奨 (#1106): 書き込みは admin スタックのみ。boolean へ投影する + // (recommendedForSharingBy = admin email は内部情報)。 + recommendedForSharing: !!item.recommendedForSharingAt, status: item.status, role: item.teacherSub === identity.sub ? 'owner' : 'co-teacher', }; @@ -734,6 +737,7 @@ async function handleGetClassroom(identity: TeacherIdentity, classroomId: string topic: result.Item.topic || null, sortDate: result.Item.sortDate || result.Item.createdAt || null, hasAssignment: hasAssignmentContent(result.Item), + recommendedForSharing: !!result.Item.recommendedForSharingAt, role: result.Item.teacherSub === identity.sub ? 'owner' : 'co-teacher', }), }; diff --git a/packages/admin/src/components/classroom-overview-view.jsx b/packages/admin/src/components/classroom-overview-view.jsx index 9747e555c8f..e7ecc8942d5 100644 --- a/packages/admin/src/components/classroom-overview-view.jsx +++ b/packages/admin/src/components/classroom-overview-view.jsx @@ -144,6 +144,12 @@ const ClassroomOverviewView = ({onOpenCandidate}) => { ) : ( {'未共有らしい'} )} + {c.recommendedForSharing ? ( + {'推奨済み'} + ) : null} {`${c.className} ・ ページ${c.pageCount}`} {c.hasImages ? '・画像あり' : ''} diff --git a/packages/admin/src/components/classrooms-view.jsx b/packages/admin/src/components/classrooms-view.jsx index 6ac49303aba..50c2cee64ff 100644 --- a/packages/admin/src/components/classrooms-view.jsx +++ b/packages/admin/src/components/classrooms-view.jsx @@ -17,7 +17,8 @@ import { fetchRestoreCandidates, fetchRestorePlan, sendNotification, - setClassroomStatus + setClassroomStatus, + setSharingRecommendation } from '../lib/admin-api.js'; import ClassroomOverviewView from './classroom-overview-view.jsx'; @@ -140,6 +141,9 @@ const ClassroomDetail = ({classroomId, onBack, onChanged}) => { const [detail, setDetail] = useState(null); const [error, setError] = useState(''); const [confirming, setConfirming] = useState(false); + // 共有推奨 (#1106) はアーカイブ切替とは独立した確認ステップ + // (同時にどちらか一方しか arm できない)。 + const [confirmingRecommend, setConfirmingRecommend] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { @@ -148,7 +152,10 @@ const ClassroomDetail = ({classroomId, onBack, onChanged}) => { .catch(err => setError(err.message)); }, [classroomId]); - const handleArm = useCallback(() => setConfirming(true), []); + const handleArm = useCallback(() => { + setConfirmingRecommend(false); + setConfirming(true); + }, []); const handleDisarm = useCallback(() => setConfirming(false), []); const handleFlip = useCallback(async () => { if (!detail) return; @@ -167,6 +174,32 @@ const ClassroomDetail = ({classroomId, onBack, onChanged}) => { } }, [detail, classroomId, onChanged]); + const handleArmRecommend = useCallback(() => { + setConfirming(false); + setConfirmingRecommend(true); + }, []); + const handleDisarmRecommend = useCallback(() => setConfirmingRecommend(false), []); + const handleFlipRecommend = useCallback(async () => { + if (!detail) return; + setBusy(true); + setError(''); + try { + const updated = await setSharingRecommendation(classroomId, !detail.recommendedForSharing); + setDetail(prev => ({ + ...prev, + recommendedForSharing: updated.recommendedForSharing, + recommendedForSharingAt: updated.recommendedForSharingAt, + recommendedForSharingBy: updated.recommendedForSharingBy + })); + setConfirmingRecommend(false); + onChanged(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }, [detail, classroomId, onChanged]); + if (error) { return (

{ {detail.className} {' '} + {' '} + {detail.recommendedForSharing ? ( + {'共有推奨中'} + ) : null}

{`課題: ${detail.assignmentName || '-'} ・ 参加コード: ${detail.joinCode}`} @@ -228,6 +268,40 @@ const ClassroomDetail = ({classroomId, onBack, onChanged}) => { {detail.status === 'active' ? 'アーカイブする' : '利用中に戻す'} )} + {' '} + {detail.status === 'active' ? ( + confirmingRecommend ? ( + + {detail.recommendedForSharing ? + '共有推奨を取り消しますか?(先生には通知されません)' : + 'この課題の共有を推奨しますか?(作成した先生にお知らせが届き、課題にバナーが出ます)'} + + + + ) : ( + + ) + ) : null} diff --git a/packages/admin/src/lib/admin-api.js b/packages/admin/src/lib/admin-api.js index 1e695c3f130..8d730903d9b 100644 --- a/packages/admin/src/lib/admin-api.js +++ b/packages/admin/src/lib/admin-api.js @@ -206,6 +206,17 @@ const executeRestore = classroomId => const sendNotification = (classroomId, {title, message}) => request('POST', '/admin/notifications', {classroomId, title, message}); +/** + * 共有推奨 (#1106): flag / unflag an assignment as "worth sharing to + * みんなの課題". Flagging notifies the owning teacher (#1111) and lights the + * banner in their editing view; withdrawal is silent. Audited server-side. + * @param {string} classroomId - classroom id + * @param {boolean} recommended - true = flag, false = withdraw + * @returns {Promise} updated classroom summary + */ +const setSharingRecommendation = (classroomId, recommended) => + request(recommended ? 'POST' : 'DELETE', `/admin/classrooms/${classroomId}/recommend-sharing`); + export { fetchClassrooms, fetchClassroom, @@ -214,5 +225,6 @@ export { fetchRestoreCandidates, fetchRestorePlan, executeRestore, - sendNotification + sendNotification, + setSharingRecommendation }; diff --git a/packages/admin/test/unit/classrooms-view.test.jsx b/packages/admin/test/unit/classrooms-view.test.jsx index bc73f5e0234..885ad253393 100644 --- a/packages/admin/test/unit/classrooms-view.test.jsx +++ b/packages/admin/test/unit/classrooms-view.test.jsx @@ -9,6 +9,7 @@ const mockFetchCandidates = jest.fn(); const mockFetchPlan = jest.fn(); const mockExecuteRestore = jest.fn(); const mockSendNotification = jest.fn(); +const mockSetSharingRecommendation = jest.fn(); jest.mock('../../src/lib/admin-api.js', () => ({ fetchClassrooms: (...args) => mockFetchClassrooms(...args), fetchClassroom: (...args) => mockFetchClassroom(...args), @@ -17,7 +18,8 @@ jest.mock('../../src/lib/admin-api.js', () => ({ fetchRestoreCandidates: (...args) => mockFetchCandidates(...args), fetchRestorePlan: (...args) => mockFetchPlan(...args), executeRestore: (...args) => mockExecuteRestore(...args), - sendNotification: (...args) => mockSendNotification(...args) + sendNotification: (...args) => mockSendNotification(...args), + setSharingRecommendation: (...args) => mockSetSharingRecommendation(...args) })); import ClassroomsView from '../../src/components/classrooms-view.jsx'; @@ -82,6 +84,7 @@ describe('ClassroomsView (issue #1084 + 俯瞰 #1106)', () => { mockFetchPlan.mockReset().mockResolvedValue(plan); mockExecuteRestore.mockReset(); mockSendNotification.mockReset(); + mockSetSharingRecommendation.mockReset(); }); test('the default tab is the overview dashboard', async () => { @@ -129,6 +132,40 @@ describe('ClassroomsView (issue #1084 + 俯瞰 #1106)', () => { {q: '', month: '2026-07', teacher: ''})); }); + test('共有推奨は二段階確認を通ってから API を呼ぶ (#1106)', async () => { + mockSetSharingRecommendation.mockResolvedValue({ + ...detail, + recommendedForSharing: true, + recommendedForSharingAt: '2026-07-25T00:00:00Z', + recommendedForSharingBy: 'admin@example.com' + }); + render(); + fireEvent.click(screen.getByTestId('classroom-admin-tab-live')); + await waitFor(() => screen.getByTestId('classroom-admin-item-c1')); + fireEvent.click(screen.getByTestId('classroom-admin-item-c1')); + await waitFor(() => screen.getByTestId('classroom-admin-detail')); + + fireEvent.click(screen.getByTestId('classroom-admin-recommend')); + expect(mockSetSharingRecommendation).not.toHaveBeenCalled(); + expect(screen.getByTestId('classroom-admin-recommend-confirm').textContent) + .toContain('お知らせが届き'); + + fireEvent.click(screen.getByTestId('classroom-admin-recommend-confirm-yes')); + await waitFor(() => expect(mockSetSharingRecommendation).toHaveBeenCalledWith('c1', true)); + // 推奨後はバッジが付き、ボタンが取り消しに変わる。 + await waitFor(() => screen.getByTestId('classroom-admin-recommended-badge')); + expect(screen.getByTestId('classroom-admin-recommend').textContent).toContain('取り消す'); + }); + + test('俯瞰候補に推奨済みバッジが出る (#1106)', async () => { + mockFetchOverview.mockResolvedValue({ + ...overview, + candidates: [{...overview.candidates[0], recommendedForSharing: true}] + }); + render(); + await waitFor(() => screen.getByTestId('overview-candidate-recommended-c1')); + }); + test('お知らせ送信は本文必須・確認後に classroomId で送る (#1111)', async () => { mockSendNotification.mockResolvedValue({notificationId: 'n1'}); render(); diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index eb7f0da3930..4e652e1087d 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -326,6 +326,7 @@ test/unit/components/* !test/unit/components/student-assignment-panel.test.jsx !test/unit/components/teacher-class-detail.test.jsx !test/unit/components/teacher-notifications.test.jsx +!test/unit/components/classroom-teacher-modal-share-suggestion.test.js !test/unit/components/mobile-bottom-tabs.test.jsx !test/unit/components/mobile-drawer.test.jsx !test/unit/components/mobile-mode-notice.test.jsx 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 c86391751ff..b1f8a8030f2 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -2832,6 +2832,52 @@ button.detail-tabs-download-urgent:hover:not(:disabled) { font-weight: normal; } +/* 共有推奨バナー (#1106): 課題詳細の上部。運営のおすすめ→共有フォーム誘導。 */ +.share-suggestion-banner { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.75rem 0; + padding: 0.6rem 0.9rem; + border: 1px solid hsla(40, 90%, 55%, 0.5); + border-radius: 0.5rem; + background: hsla(40, 90%, 55%, 0.12); + color: hsla(30, 80%, 30%, 1); +} + +.share-suggestion-text { + flex: 1; + font-size: 0.9rem; + line-height: 1.4; +} + +.share-suggestion-button { + flex: none; + padding: 0.4rem 1rem; + border: none; + border-radius: 0.4rem; + background: hsla(30, 80%, 45%, 1); + color: white; + font-size: 0.85rem; + cursor: pointer; +} + +.share-suggestion-button:hover:not(:disabled) { + background: hsla(30, 80%, 38%, 1); +} + +/* ボード行の共有推奨マーク (#1106)。推薦バッジ (#1110) と同系色。 */ +.board-row-share-suggested { + flex: none; + margin-left: 0.4rem; + padding: 0.05rem 0.4rem; + border-radius: 0.25rem; + background: hsla(40, 90%, 55%, 0.2); + color: hsla(30, 80%, 35%, 1); + font-size: 0.7rem; + font-weight: bold; +} + /* 運営の推薦バッジ (#1110): 金色系で「選ばれた」ことが一目でわかる形。 */ .shared-card-recommended { padding: 0.05rem 0.4rem; diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx index d2be7ee6e80..ea2f22ea574 100644 --- a/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx +++ b/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx @@ -152,6 +152,19 @@ const AssignmentRow = ({ {classroom.assignmentName || classroom.className} + {/* 共有推奨マーク (#1106): 運営が共有をおすすめした課題。 */} + {classroom.recommendedForSharing ? ( + + + + ) : null} {classroom.joinCode}