diff --git a/docs/admin/README.md b/docs/admin/README.md index 4f05afd13a6..8f4c2943142 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -16,7 +16,7 @@ ### セクション(3 ドメイン) -1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit) +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 で、この面が単一の書き手) 3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる @@ -91,3 +91,4 @@ cd tools/playwright-verify && node verify-admin.mjs | `screenshots/0104-restore-plan.png` | 期限切れ復元: dry-run プラン | | `screenshots/0105-bug-reports.png` | バグ報告: 閲覧一覧 | | `screenshots/0106-classroom-notify.png` | クラス詳細: 先生へのお知らせ送信(EPIC #1111) | +| `screenshots/0107-shared-recommend.png` | みんなの課題: 限定公開の詳細と推薦の確認(EPIC #1110) | diff --git a/docs/admin/screenshots/0107-shared-recommend.png b/docs/admin/screenshots/0107-shared-recommend.png new file mode 100644 index 00000000000..460972ba90f Binary files /dev/null and b/docs/admin/screenshots/0107-shared-recommend.png differ diff --git a/docs/assignment-sharing/README.md b/docs/assignment-sharing/README.md index 8305a638883..78edbc848ab 100644 --- a/docs/assignment-sharing/README.md +++ b/docs/assignment-sharing/README.md @@ -51,6 +51,27 @@ ![取り込み完了](screenshots/0104-imported.png) +## 限定公開と運営の推薦(#1109 / #1110) + +共有時に**公開範囲**を選べる: `public`(みんなの課題カタログ)/ `limited`(**合言葉限定公開** — 参加コード同型の合言葉を知っている人だけが取り込める内輪公開。CC BY 同意・属性・著者名は任意)。 + +```text +限定公開(合言葉・内輪)→ Admin が把握(限定公開タブ)→ 推薦 → 先生が全体公開に広げる +``` + +- **推薦(Admin)**: 運営が Admin SPA から「推薦する」と、`recommendedAt`/`recommendedBy` が付き、**作成した先生へお知らせセンター(#1111・`link.kind='shared-mine'`)で通知**が届く。取り消しは通知なし(audit のみ) +- **推薦印(先生)**: 「自分の投稿」のカードに「推薦」バッジ(限定公開バッジと並ぶ)。詳細には推薦済みの注記 +- **全体公開への発展(先生)**: 自分の限定公開の詳細に「**みんなの課題に公開する**」。共有フォームが**編集モード**(既存メタデータが初期値)で開き、全体公開に必要な属性・著者名・**CC BY 4.0 同意(改めて必須)**を揃えて送信すると `PATCH visibility: 'public'` でカタログに載る +- 推薦通知をクリックすると「自分の投稿」へ直接ジャンプする(クラス未選択ならアクティブな先頭クラスを開いてから表示) + +### 自分の投稿の推薦バッジ + +![自分の投稿と推薦バッジ](screenshots/0105-mine-recommended.png) + +### 全体公開フォーム(編集モード) + +![全体公開フォーム](screenshots/0106-broaden-form.png) + ## 主要ファイル ### バックエンド(infra/smalruby-classroom) diff --git a/docs/assignment-sharing/screenshots/0105-mine-recommended.png b/docs/assignment-sharing/screenshots/0105-mine-recommended.png new file mode 100644 index 00000000000..76d326100c7 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0105-mine-recommended.png differ diff --git a/docs/assignment-sharing/screenshots/0106-broaden-form.png b/docs/assignment-sharing/screenshots/0106-broaden-form.png new file mode 100644 index 00000000000..26bf107e540 Binary files /dev/null and b/docs/assignment-sharing/screenshots/0106-broaden-form.png differ diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index d6d1d8d6a69..a9d28361c91 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -208,6 +208,7 @@ sequenceDiagram | `POST` | `/shared-assignments/{id}/report` | 通報(理由必須・20件/日制限。reporterSub は内部保持のみ) | - **公開範囲(#1109)**: 項目は `visibility`(`public`/`limited`)を持つ。#1109 以前の項目は属性を持たず `public` とみなす(後方互換)。`limited` は `passcode`(合言葉)を持ち、公開カタログには出ない。「限定公開(合言葉・内輪)→ Admin が把握 → 推薦 → 全体公開」パイプラインの土台 +- **Admin 推薦(#1110)**: 項目は `recommendedAt` / `recommendedBy`(admin email)を持ちうる。書き込みは admin API(`POST/DELETE /admin/shared-assignments/{id}/recommend`)のみ。先生側 API には boolean の `recommended` だけを投影(`recommendedBy` は内部情報)。推薦時は著者へお知らせ(#1111・type `shared_recommended`・`link.kind='shared-mine'`)が飛ぶ - データ: `SharedAssignments{suffix}`(**TTL なし・prod は RETAIN + PITR**。GSI: `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(合言葉ルックアップ・#1109))、`SharedAssignmentReports{suffix}`(TTL 90日) - ファイル: 専用バケット `smalruby-shared-assignments{suffix}`(**lifecycle なし = 永続**、`shared/{sharedId}/` プレフィックス)。クラス側の保存期限と完全に分離 - 共有/取り込みの実体は既存 duplicate と同じ S3 サーバー側コピー(クロスバケット) diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index ebb126a5d2a..4ef6ad23c6d 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -275,6 +275,10 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `shared-report-form` / `shared-report-reason` / `shared-report-submit` | div/textarea/button | 通報理由(必須)と送信 | | `shared-report-sent` | p | 通報完了メッセージ | | `shared-detail-unlist` / `shared-detail-republish` | button | 自分の投稿の取り下げ / 再公開 | +| `shared-card-limited-badge` / `shared-card-recommended-badge` | span | カードの 限定公開 / 推薦 バッジ(#1110) | +| `shared-detail-recommended-note` | p | 自分の限定公開が推薦を受けたときの注記(#1110) | +| `shared-detail-broaden` | button | 「みんなの課題に公開する」(自分の限定公開のみ・#1110) | +| `shared-broaden-done` | p | 全体公開の完了メッセージ(#1110) | | `shared-import-success` | p | 取り込み完了メッセージ(ボード上) | | `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) | | `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク | diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index 8ea115a1fad..d45bfa0a817 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -226,6 +226,12 @@ function mapSharedItemForAdmin(item: Record) { reuseCount: (item.reuseCount as number) || 0, pageCount: (content.pages || []).length, hasStarter: !!content.starterKey, + // 公開範囲 (#1109) と Admin 推薦 (#1110)。passcode 自体は運営にも不要 + // なので出さない(最小露出)。 + visibility: (item.visibility as string) || 'public', + recommended: !!item.recommendedAt, + recommendedAt: item.recommendedAt || null, + recommendedBy: item.recommendedBy || null, createdAt: item.createdAt, updatedAt: item.updatedAt, }; @@ -299,11 +305,17 @@ async function handleListSharedReports(identity: AdminIdentity): Promise, ): Promise { - audit('shared.list', identity, { q: query.q || null, status: query.status || null }); + audit('shared.list', identity, { + q: query.q || null, status: query.status || null, visibility: query.visibility || null, + }); let items = (await scanAll(SHARED_ASSIGNMENTS_TABLE)).map(mapSharedItemForAdmin); if (query.status) { items = items.filter(item => item.status === query.status); } + // 限定公開の把握 (#1110): 推薦候補の母集団を絞る。 + if (query.visibility) { + items = items.filter(item => item.visibility === query.visibility); + } if (query.q) { const q = query.q.toLowerCase(); items = items.filter(item => @@ -382,6 +394,95 @@ async function handleSetSharedStatus( }; } +// --- Admin 推薦 (EPIC #1110): 限定公開 → みんなの課題への発展 --- + +/** + * POST/DELETE /admin/shared-assignments/{sharedId}/recommend — mark a shared + * assignment as operator-recommended (or withdraw the mark). Recommending + * notifies the author through the notification center (#1111) so the teacher + * can broaden a 限定公開 item to the public catalog. Idempotent: recommending + * an already-recommended item neither rewrites the mark nor re-notifies. + */ +async function handleSetSharedRecommendation( + identity: AdminIdentity, sharedId: string, recommended: boolean, +): Promise { + const result = await docClient.send(new GetCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + })); + const item = result.Item as Record | undefined; + if (!item || item.status !== 'published') { + // Unlisted items are not recommendable — the author withdrew them. + throw new NotFoundError('Shared assignment not found'); + } + + const alreadyRecommended = !!item.recommendedAt; + if (recommended && !alreadyRecommended) { + // 推薦は「限定公開 → 全体公開への発展」の働きかけ (#1110)。公開済みの + // 項目に送っても通知文(全体公開の検討を促す)が意味を成さないため + // 限定公開に限定する(レビュー指摘)。取り消し (下) は無条件に許す。 + if (((item.visibility as string) || 'public') !== 'limited') { + throw new ValidationError('Only limited-visibility items can be recommended'); + } + const now = new Date().toISOString(); + // 通知が主目的なので先に通知 → 印付けの順にする。印付けが失敗しても + // リトライで再通知 + 印付けが成立する(逆順だと印だけ付いて通知が + // 永久に失われ、no-op ガードで再送もできない — レビュー指摘)。最悪 + // ケースは通知の重複で、先生側では既読で流せる無害な事象。 + const authorSub = typeof item.authorSub === 'string' ? item.authorSub : ''; + if (authorSub) { + await putNotification(authorSub, { + type: 'shared_recommended', + title: 'あなたの課題が推薦されました', + body: `「${String(item.title)}」が運営の推薦を受けました。みんなの課題への全体公開を検討してみませんか?`, + link: { kind: 'shared-mine', sharedId }, + createdBy: identity.email, + }); + } + try { + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'SET recommendedAt = :now, recommendedBy = :email, updatedAt = :now', + // 2 人の運営が同時に推薦しても印付けと通知が二重にならないよう、 + // 冪等判定を原子的にする(レビュー指摘)。 + ConditionExpression: 'attribute_not_exists(recommendedAt)', + ExpressionAttributeValues: { ':now': now, ':email': identity.email }, + })); + } catch (err) { + // 競合で先に推薦されていた場合は already-recommended と同じ扱い。 + if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err; + } + audit('shared.recommend', identity, { sharedId }); + return { + statusCode: 200, + body: JSON.stringify(mapSharedItemForAdmin({ + ...item, recommendedAt: now, recommendedBy: identity.email, + })), + }; + } + + if (!recommended && alreadyRecommended) { + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'REMOVE recommendedAt, recommendedBy SET updatedAt = :now', + ExpressionAttributeValues: { ':now': new Date().toISOString() }, + })); + // 取り消しは通知しない(先生を騒がせない)。audit で追跡できる。 + audit('shared.unrecommend', identity, { sharedId }); + return { + statusCode: 200, + body: JSON.stringify(mapSharedItemForAdmin({ + ...item, recommendedAt: undefined, recommendedBy: undefined, + })), + }; + } + + // No-op (already in the requested state) — idempotent success. + return { statusCode: 200, body: JSON.stringify(mapSharedItemForAdmin(item)) }; +} + // --- Classroom management + expired restore (S4 #1084) --- // The restore UI supersedes classroom's ops CLI (EPIC #1049 D6 update): // snapshots written by the classroom archiver under @@ -509,8 +610,10 @@ async function handleSetClassroomStatus( * Notification link targets the teacher UI knows how to open. Kept as a * whitelist so a typo'd kind can never be stored (the editor ignores * unknown kinds, but the audit trail should stay clean). + * - 'classroom': open the referenced assignment in its class board context + * - 'shared-mine': open みんなの課題 の「自分の投稿」 (#1110 recommendation) */ -const NOTIFICATION_LINK_KINDS = new Set(['classroom']); +const NOTIFICATION_LINK_KINDS = new Set(['classroom', 'shared-mine']); /** * Write one notice into the teacher's inbox (single-writer: only this stack @@ -844,6 +947,14 @@ 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, sharedId: string) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + pathParameters: { sharedId }, +}); + +const limitedItem = (over: Record = {}) => ({ + sharedId: 's1', + title: 'ねこあつめ入門', + authorSub: 'teacher-sub-9', + visibility: 'limited', + passcode: 'abc234', + status: 'published', + reuseCount: 0, + createdAt: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-07-20T00:00:00.000Z', + ...over, +}); + +describe('POST/DELETE /admin/shared-assignments/{id}/recommend (EPIC #1110)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + process.env.ADMIN_GOOGLE_CLIENT_ID = 'admin-client-id'; + process.env.CORS_ALLOWED_ORIGINS = 'https://smalruby.app,http://localhost:8602'; + mockSend.mockReset(); + mockVerifyIdToken.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + /** + * Route DynamoDB commands for the recommend flow. + * @param shared - the SharedAssignments item (null = missing) + * @returns collected {updates, puts} command inputs + */ + const wireMocks = (shared: Record | null) => { + const updates: Array> = []; + const puts: Array> = []; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'GetCommand' && table?.startsWith('SharedAssignments')) { + return { Item: shared }; + } + if (name === 'UpdateCommand') { + updates.push(command.input as Record); + return {}; + } + if (name === 'PutCommand') { + puts.push(command.input as Record); + return {}; + } + return {}; + }); + return { updates, puts }; + }; + + test('recommend marks the item and notifies the author (#1111 inbox)', async () => { + const { updates, puts } = wireMocks(limitedItem()); + const commandOrder: string[] = []; + const prevImpl = mockSend.getMockImplementation()!; + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + commandOrder.push(command.constructor.name); + return prevImpl(command); + }); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('SET recommendedAt'); + // 冪等判定は原子的(同時 POST の二重通知防止)。 + expect(updates[0].ConditionExpression).toContain('attribute_not_exists(recommendedAt)'); + // 通知が主目的なので Put(通知)→ Update(印付け)の順。逆だと印付け後の + // 通知失敗をリトライで回復できない。 + expect(commandOrder.indexOf('PutCommand')).toBeLessThan(commandOrder.indexOf('UpdateCommand')); + + expect(puts).toHaveLength(1); + const notice = puts[0].Item as Record; + expect(notice.teacherSub).toBe('teacher-sub-9'); + expect(notice.type).toBe('shared_recommended'); + expect(String(notice.body)).toContain('ねこあつめ入門'); + expect(notice.link).toEqual({ kind: 'shared-mine', sharedId: 's1' }); + + const body = JSON.parse(res.body as string); + expect(body.recommended).toBe(true); + expect(body.recommendedBy).toBe('dev-admin@example.com'); + // passcode never crosses to the SPA even for operators. + expect(res.body).not.toContain('abc234'); + expect(res.body).not.toContain('authorSub'); + }); + + test('recommending an already-recommended item is a no-op (no re-notify)', async () => { + const { updates, puts } = wireMocks(limitedItem({ + recommendedAt: '2026-07-21T00:00:00.000Z', recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommended).toBe(true); + }); + + test('withdrawal removes the mark without notifying', async () => { + const { updates, puts } = wireMocks(limitedItem({ + recommendedAt: '2026-07-21T00:00:00.000Z', recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + expect(updates[0].UpdateExpression).toContain('REMOVE recommendedAt, recommendedBy'); + expect(puts).toHaveLength(0); + expect(JSON.parse(res.body as string).recommended).toBe(false); + }); + + test('400 when recommending a public item (発展の働きかけ先ではない)', async () => { + const { updates, puts } = wireMocks(limitedItem({ visibility: 'public', passcode: undefined })); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(400); + expect(updates).toHaveLength(0); + expect(puts).toHaveLength(0); + }); + + test('withdrawal still works after the item became public (推薦は残っている)', async () => { + const { updates } = wireMocks(limitedItem({ + visibility: 'public', + passcode: undefined, + recommendedAt: '2026-07-21T00:00:00.000Z', + recommendedBy: 'dev-admin@example.com', + })); + const res = await handler(makeEvent('DELETE', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + }); + + test('404 for missing or unlisted items (author withdrew it)', async () => { + for (const item of [null, limitedItem({ status: 'unlisted' })]) { + wireMocks(item); + const res = await handler(makeEvent('POST', '/admin/shared-assignments/s1/recommend', 's1')); + expect(res.statusCode).toBe(404); + } + }); + + test('visibility filter narrows the admin list (#1110 候補の母集団)', async () => { + mockSend.mockImplementation(async (command: { + constructor: { name: string }; input?: Record; + }) => { + const name = command.constructor.name; + const table = command.input?.TableName as string | undefined; + if (name === 'GetCommand' && table?.startsWith('SmalrubyAdmins')) { + return { Item: { email: 'dev-admin@example.com', sub: 'dev-admin' } }; + } + if (name === 'ScanCommand') { + return { + Items: [ + limitedItem(), + limitedItem({ sharedId: 's2', visibility: 'public', passcode: undefined }), + limitedItem({ sharedId: 's3' }), // visibility 'limited' + ], + }; + } + return {}; + }); + const res = await handler({ + requestContext: { http: { method: 'GET', path: '/admin/shared-assignments', sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' }, + queryStringParameters: { visibility: 'limited' }, + }); + expect(res.statusCode).toBe(200); + const { items } = JSON.parse(res.body as string); + expect(items.map((i: { sharedId: string }) => i.sharedId).sort()).toEqual(['s1', 's3']); + expect(items.every((i: { visibility: string }) => i.visibility === 'limited')).toBe(true); + }); +}); diff --git a/infra/smalruby-admin/lib/admin-stack.ts b/infra/smalruby-admin/lib/admin-stack.ts index 33e39bec4ac..541a4a9e98a 100644 --- a/infra/smalruby-admin/lib/admin-stack.ts +++ b/infra/smalruby-admin/lib/admin-stack.ts @@ -273,6 +273,9 @@ export class SmalrubyAdminStack extends cdk.Stack { addRoute('/admin/shared-assignments/reports', [apigatewayv2.HttpMethod.GET]); addRoute('/admin/shared-assignments/{sharedId}', [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]); + // Admin 推薦 (#1110) + addRoute('/admin/shared-assignments/{sharedId}/recommend', + [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.DELETE]); // Classroom management + expired restore (S4 #1084). HTTP API prefers // the literal restore-candidates route over {classroomId} by specificity. diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 0452a8a47e1..c4ea0689a9a 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -3637,6 +3637,9 @@ function mapSharedSummary(item: Record, opts: { includePasscode reuseCount: (item.reuseCount as number) || 0, // 公開範囲: 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。 visibility: (item.visibility as string) || 'public', + // Admin 推薦 (#1110): 書き込みは admin スタックのみ。boolean へ投影する + // (recommendedBy = admin email は内部情報なので先生側 API には出さない)。 + recommended: !!item.recommendedAt, status: item.status, createdAt: item.createdAt, updatedAt: item.updatedAt, @@ -4185,16 +4188,21 @@ async function handleUpdateSharedAssignment( if (body.licenseConsent !== true) { throw new ValidationError('licenseConsent (CC BY 4.0) is required to make it public'); } + // `??` だと body の明示的な null(= クリア)が既存値へ巻き戻る + // (例: 発展フォームでコマ数を空にしても旧値が残る)。他の per-field + // ブロックと同じ「undefined のときだけ既存値」で合成する。 Object.assign(updates, validateSharedAttributes({ - schoolLevel: body.schoolLevel ?? item.schoolLevel, - subject: body.subject ?? item.subject, - grades: body.grades ?? item.grades, - tags: body.tags ?? item.tags, - lessonCount: body.lessonCount ?? item.lessonCount, + schoolLevel: body.schoolLevel !== undefined ? body.schoolLevel : item.schoolLevel, + subject: body.subject !== undefined ? body.subject : item.subject, + grades: body.grades !== undefined ? body.grades : item.grades, + tags: body.tags !== undefined ? body.tags : item.tags, + lessonCount: body.lessonCount !== undefined ? body.lessonCount : item.lessonCount, } as Record)); Object.assign(updates, validateAuthorProfile({ - authorName: body.authorName ?? item.authorName, - authorAffiliation: body.authorAffiliation ?? item.authorAffiliation, + authorName: body.authorName !== undefined ? body.authorName : item.authorName, + authorAffiliation: body.authorAffiliation !== undefined + ? body.authorAffiliation + : item.authorAffiliation, })); updates.visibility = 'public'; } else if (body.visibility === 'limited' && currentVisibility !== 'limited') { diff --git a/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts b/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts new file mode 100644 index 00000000000..43e5a92eabc --- /dev/null +++ b/infra/smalruby-classroom/lambda/tests/handler-shared-recommend-projection.test.ts @@ -0,0 +1,169 @@ +/** + * 先生側 API と Admin 推薦 (#1110) の境界のテスト。 + * + * - 一覧/詳細の投影は boolean の recommended だけ(recommendedBy は内部情報) + * - 先生の PATCH からは recommendedAt/recommendedBy を書けない(whitelist) + * - 限定公開 → 全体公開の PATCH で明示的な null(クリア)が既存値に + * 巻き戻らない(レビュー指摘の回帰テスト) + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => { + const actual = jest.requireActual('@aws-sdk/lib-dynamodb'); + return { + ...actual, + DynamoDBDocumentClient: { from: () => ({ send: mockSend }) }, + }; +}); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn(() => ({ send: mockS3Send })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/get'), +})); + +const DEV_TOKEN = 'test-dev-bypass'; + +const makeEvent = ( + method: string, + path: string, + pathParameters: Record, + body?: unknown, + query?: Record, +) => ({ + requestContext: { http: { method, path, sourceIp: '127.0.0.1' } }, + headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'http://localhost:8601' }, + pathParameters, + queryStringParameters: query, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const myLimitedItem = (over: Record = {}) => ({ + sharedId: 's1', + title: 'ねこあつめ入門', + summary: '説明', + content: { pages: [{ text: 'ページ1' }] }, + schoolLevel: 'junior-high', + grades: [1], + subject: '技術・家庭(技術分野)', + tags: ['甲子園'], + lessonCount: 3, + supplementUrl: null, + authorName: 'るびお', + authorAffiliation: '島根県', + authorSub: 'dev-test-teacher', + visibility: 'limited', + passcode: 'abc234', + status: 'published', + reuseCount: 0, + recommendedAt: '2026-07-21T00:00:00.000Z', + recommendedBy: 'admin@example.com', + createdAt: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-07-20T00:00:00.000Z', + ...over, +}); + +describe('先生側 API と Admin 推薦の境界 (#1110)', () => { + let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>; + + beforeEach(() => { + jest.resetModules(); + process.env.DEV_BYPASS_TOKEN = DEV_TOKEN; + process.env.STAGE = 'stg'; + mockSend.mockReset(); + mockS3Send.mockReset(); + const mod = require('../handler'); + handler = mod.handler; + }); + + test('詳細投影は recommended (boolean) のみ — recommendedBy を出さない', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') return { Item: myLimitedItem() }; + return {}; + }); + const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' })); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body || '{}'); + expect(data.recommended).toBe(true); + expect(res.body).not.toContain('recommendedBy'); + expect(res.body).not.toContain('admin@example.com'); + }); + + test('先生の PATCH は recommendedAt/recommendedBy を無視する (whitelist)', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') { + return { Item: myLimitedItem({ recommendedAt: undefined, recommendedBy: undefined }) }; + } + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + title: '改題', + recommendedAt: '2026-07-25T00:00:00.000Z', + recommendedBy: 'attacker@example.com', + })); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + const expr = String(updates[0].UpdateExpression); + expect(expr).not.toContain('recommended'); + expect(JSON.stringify(updates[0].ExpressionAttributeValues)).not.toContain('attacker'); + }); + + test('限定公開→全体公開: 明示的な null クリアが既存値へ巻き戻らない', async () => { + const updates: Array> = []; + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'GetCommand') return { Item: myLimitedItem() }; + if (command.constructor.name === 'UpdateCommand') { + updates.push(command.input); + return {}; + } + return {}; + }); + const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, { + visibility: 'public', + licenseConsent: true, + schoolLevel: 'junior-high', + subject: '技術・家庭(技術分野)', + grades: [1], + tags: ['甲子園'], + // クリア(発展フォームで空にした想定) + lessonCount: null, + authorAffiliation: null, + authorName: 'るびお', + })); + expect(res.statusCode).toBe(200); + expect(updates).toHaveLength(1); + const values = updates[0].ExpressionAttributeValues as Record; + const names = updates[0].ExpressionAttributeNames as Record | undefined; + const expr = String(updates[0].UpdateExpression); + // クリアした値が旧値 (3 / 島根県) に戻っていないこと。 + const serialized = JSON.stringify({ expr, values, names }); + expect(serialized).toContain('public'); + const lessonKey = Object.entries(values).find(([, v]) => v === 3); + expect(lessonKey).toBeUndefined(); + expect(serialized).not.toContain('島根県'); + }); + + test('一覧 (mine) 投影にも recommended が載る', async () => { + mockSend.mockImplementation(async (command) => { + if (command.constructor.name === 'QueryCommand') return { Items: [myLimitedItem()] }; + return {}; + }); + const res = await handler(makeEvent('GET', '/shared-assignments', {}, undefined, { mine: '1' })); + expect(res.statusCode).toBe(200); + const { items } = JSON.parse(res.body || '{}'); + expect(items[0].recommended).toBe(true); + expect(res.body).not.toContain('recommendedBy'); + }); +}); diff --git a/packages/admin/src/components/shared-assignments-view.jsx b/packages/admin/src/components/shared-assignments-view.jsx index aa1bac93484..d933fb1ece0 100644 --- a/packages/admin/src/components/shared-assignments-view.jsx +++ b/packages/admin/src/components/shared-assignments-view.jsx @@ -9,6 +9,7 @@ import { fetchSharedAssignment, fetchSharedAssignments, fetchSharedReports, + setSharedRecommendation, setSharedStatus } from '../lib/admin-api.js'; @@ -20,10 +21,35 @@ const StatusBadge = ({status}) => ( StatusBadge.propTypes = {status: PropTypes.string}; +// 公開範囲 (#1109) と推薦 (#1110) のバッジ。限定公開は合言葉でしか届かない +// 内輪公開なので、運営が推薦候補を見分けられるよう明示する。 +const VisibilityBadge = ({visibility}) => + (visibility === 'limited' ? ( + {'限定公開'} + ) : null); + +VisibilityBadge.propTypes = {visibility: PropTypes.string}; + +const RecommendedBadge = ({recommended}) => + (recommended ? ( + {'推薦中'} + ) : null); + +RecommendedBadge.propTypes = {recommended: PropTypes.bool}; + const SharedDetail = ({sharedId, onBack, onChanged}) => { const [detail, setDetail] = useState(null); const [error, setError] = useState(''); const [confirming, setConfirming] = useState(false); + // 推薦 (#1110) は非公開化とは別の確認ステップを持つ(誤操作防止のため + // 同時にどちらか一方しか arm できない)。 + const [confirmingRecommend, setConfirmingRecommend] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { @@ -32,7 +58,10 @@ const SharedDetail = ({sharedId, onBack, onChanged}) => { .catch(err => setError(err.message)); }, [sharedId]); - const handleArm = useCallback(() => setConfirming(true), []); + const handleArm = useCallback(() => { + setConfirmingRecommend(false); + setConfirming(true); + }, []); const handleDisarm = useCallback(() => setConfirming(false), []); const handleFlip = useCallback(async () => { if (!detail) return; @@ -51,6 +80,32 @@ const SharedDetail = ({sharedId, onBack, onChanged}) => { } }, [detail, sharedId, onChanged]); + const handleArmRecommend = useCallback(() => { + setConfirming(false); + setConfirmingRecommend(true); + }, []); + const handleDisarmRecommend = useCallback(() => setConfirmingRecommend(false), []); + const handleFlipRecommend = useCallback(async () => { + if (!detail) return; + setBusy(true); + setError(''); + try { + const updated = await setSharedRecommendation(sharedId, !detail.recommended); + setDetail(prev => ({ + ...prev, + recommended: updated.recommended, + recommendedAt: updated.recommendedAt, + recommendedBy: updated.recommendedBy + })); + setConfirmingRecommend(false); + onChanged(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }, [detail, sharedId, onChanged]); + if (error) { return (

{ {detail.title} {' '} + {' '} + + {' '} +

{ {detail.status === 'published' ? '非公開にする' : '再公開する'} )} + {' '} + {/* 推薦は限定公開のみ(サーバー側も同じ制約)。ただし推薦済みなら + 公開に広がった後でも取り消しは出す。 */} + {detail.status === 'published' && (detail.visibility === 'limited' || detail.recommended) ? ( + confirmingRecommend ? ( + + {detail.recommended ? + '推薦を取り消しますか?(先生には通知されません)' : + 'この課題を推薦しますか?(作成した先生にお知らせが届きます)'} + + + + ) : ( + + ) + ) : null} ); @@ -181,7 +276,8 @@ const SharedAssignmentsView = () => { .then(data => setQueue(data.queue || [])) .catch(err => setError(err.message)); } else { - fetchSharedAssignments() + // 限定公開タブ (#1110) は推薦候補の母集団: visibility で絞る。 + fetchSharedAssignments(tab === 'limited' ? {visibility: 'limited'} : {}) .then(data => setItems(data.items || [])) .catch(err => setError(err.message)); } @@ -197,6 +293,10 @@ const SharedAssignmentsView = () => { setTab('all'); setSelectedId(null); }, []); + const handleTabLimited = useCallback(() => { + setTab('limited'); + setSelectedId(null); + }, []); const handleOpen = useCallback(e => setSelectedId(e.currentTarget.dataset.sharedId), []); const handleBack = useCallback(() => setSelectedId(null), []); @@ -225,6 +325,12 @@ const SharedAssignmentsView = () => { type="button" onClick={handleTabAll} >{'すべての投稿'} + {error ?

{ > {item.title} + + {`${item.authorName} ・ 取り込み ${item.reuseCount} 回 ・ ${item.createdAt}`} diff --git a/packages/admin/src/lib/admin-api.js b/packages/admin/src/lib/admin-api.js index 32c902b3737..1e695c3f130 100644 --- a/packages/admin/src/lib/admin-api.js +++ b/packages/admin/src/lib/admin-api.js @@ -79,13 +79,14 @@ const fetchSharedReports = () => request('GET', '/admin/shared-assignments/repor /** * Fleet-wide shared assignment list. - * @param {object} [filters] - {status?, q?} + * @param {object} [filters] - {status?, q?, visibility?} * @returns {Promise} {items} */ const fetchSharedAssignments = (filters = {}) => { const params = new URLSearchParams(); if (filters.status) params.set('status', filters.status); if (filters.q) params.set('q', filters.q); + if (filters.visibility) params.set('visibility', filters.visibility); const qs = params.toString(); return request('GET', `/admin/shared-assignments${qs ? `?${qs}` : ''}`); }; @@ -106,7 +107,24 @@ const fetchSharedAssignment = sharedId => request('GET', `/admin/shared-assignme const setSharedStatus = (sharedId, status) => request('PATCH', `/admin/shared-assignments/${sharedId}`, {status}); -export {fetchSharedReports, fetchSharedAssignments, fetchSharedAssignment, setSharedStatus}; +/** + * Admin 推薦 (#1110): mark / unmark a shared assignment as recommended. + * Recommending notifies the author through the notification center (#1111); + * withdrawal is silent. Audited server-side. + * @param {string} sharedId - shared assignment id + * @param {boolean} recommended - true = recommend, false = withdraw + * @returns {Promise} updated summary + */ +const setSharedRecommendation = (sharedId, recommended) => + request(recommended ? 'POST' : 'DELETE', `/admin/shared-assignments/${sharedId}/recommend`); + +export { + fetchSharedReports, + fetchSharedAssignments, + fetchSharedAssignment, + setSharedStatus, + setSharedRecommendation +}; // --- クラス・課題管理 + 期限切れ復元 (S4 #1084) --- diff --git a/packages/admin/test/unit/shared-assignments-view.test.jsx b/packages/admin/test/unit/shared-assignments-view.test.jsx index d83b53bbf2e..0bb892bf0f0 100644 --- a/packages/admin/test/unit/shared-assignments-view.test.jsx +++ b/packages/admin/test/unit/shared-assignments-view.test.jsx @@ -5,11 +5,13 @@ const mockFetchReports = jest.fn(); const mockFetchList = jest.fn(); const mockFetchDetail = jest.fn(); const mockSetStatus = jest.fn(); +const mockSetRecommendation = jest.fn(); jest.mock('../../src/lib/admin-api.js', () => ({ fetchSharedReports: (...args) => mockFetchReports(...args), fetchSharedAssignments: (...args) => mockFetchList(...args), fetchSharedAssignment: (...args) => mockFetchDetail(...args), - setSharedStatus: (...args) => mockSetStatus(...args) + setSharedStatus: (...args) => mockSetStatus(...args), + setSharedRecommendation: (...args) => mockSetRecommendation(...args) })); import SharedAssignmentsView from '../../src/components/shared-assignments-view.jsx'; @@ -42,6 +44,7 @@ describe('SharedAssignmentsView (issue #1083)', () => { mockFetchList.mockReset().mockResolvedValue({items: [queueEntry.item]}); mockFetchDetail.mockReset().mockResolvedValue(detail); mockSetStatus.mockReset(); + mockSetRecommendation.mockReset(); }); test('shows the report queue with counts and reasons', async () => { @@ -97,6 +100,43 @@ describe('SharedAssignmentsView (issue #1083)', () => { expect(screen.queryByTestId('shared-admin-starter-download')).not.toBeInTheDocument(); }); + test('限定公開タブは visibility フィルタで一覧を絞る (#1110)', async () => { + mockFetchList.mockResolvedValue({ + items: [{...queueEntry.item, visibility: 'limited', recommended: true}] + }); + render(); + fireEvent.click(screen.getByTestId('shared-admin-tab-limited')); + await waitFor(() => expect(screen.getByTestId('shared-admin-list')).toBeInTheDocument()); + expect(mockFetchList).toHaveBeenCalledWith({visibility: 'limited'}); + expect(screen.getByTestId('shared-admin-limited-badge')).toBeInTheDocument(); + expect(screen.getByTestId('shared-admin-recommended-badge')).toBeInTheDocument(); + }); + + test('推薦は二段階確認を通ってから API を呼ぶ (#1110)', async () => { + mockFetchDetail.mockResolvedValue({...detail, visibility: 'limited', recommended: false}); + mockSetRecommendation.mockResolvedValue({ + ...detail, + recommended: true, + recommendedAt: '2026-07-25T00:00:00Z', + recommendedBy: 'admin@example.com' + }); + render(); + await waitFor(() => screen.getByTestId('shared-admin-queue-item-s1')); + fireEvent.click(screen.getByTestId('shared-admin-queue-item-s1')); + await waitFor(() => screen.getByTestId('shared-admin-detail')); + + fireEvent.click(screen.getByTestId('shared-admin-recommend')); + expect(mockSetRecommendation).not.toHaveBeenCalled(); + expect(screen.getByTestId('shared-admin-recommend-confirm').textContent) + .toContain('お知らせが届きます'); + + fireEvent.click(screen.getByTestId('shared-admin-recommend-confirm-yes')); + await waitFor(() => expect(mockSetRecommendation).toHaveBeenCalledWith('s1', true)); + // 推薦後はボタンが取り消しに変わる。 + await waitFor(() => expect(screen.getByTestId('shared-admin-recommend').textContent) + .toContain('推薦を取り消す')); + }); + test('the confirmation can be cancelled without any API call', async () => { render(); await waitFor(() => screen.getByTestId('shared-admin-queue-item-s1')); 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 5967fcb102e..c86391751ff 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,16 @@ button.detail-tabs-download-urgent:hover:not(:disabled) { font-weight: normal; } +/* 運営の推薦バッジ (#1110): 金色系で「選ばれた」ことが一目でわかる形。 */ +.shared-card-recommended { + 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; +} + .shared-card-summary { font-size: 0.8rem; color: hsla(225, 15%, 40%, 1); 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 index e4847c06596..c7308af6deb 100644 --- a/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx +++ b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx @@ -11,6 +11,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { SCHOOL_LEVELS, SUBJECTS_BY_LEVEL } from '../../lib/shared-assignment-taxonomy.js'; +import SharedAssignmentForm from './shared-assignment-form.jsx'; import styles from './classroom-modal.css'; // Static message map so the level label can be resolved at runtime from the @@ -60,6 +61,24 @@ const CatalogCard = ({ item, onOpenDetail }) => { /> ) : null} + {item.visibility === 'limited' ? ( + + + + ) : null} + {item.recommended ? ( + + + + ) : null} {item.summary ? {item.summary} : null} @@ -107,6 +126,7 @@ const SharedAssignmentDetail = ({ detail, group, isLoading, + onOpenBroaden, reportSent, onClose, onImport, @@ -139,6 +159,7 @@ const SharedAssignmentDetail = ({ () => onSetStatus(detail.sharedId, 'published'), [onSetStatus, detail.sharedId], ); + const handleBroaden = useCallback(() => onOpenBroaden(detail), [onOpenBroaden, detail]); let urlDomain = ''; if (detail.supplementUrl) { @@ -235,7 +256,32 @@ const SharedAssignmentDetail = ({ ) : null} + {/* 推薦を受けた自分の限定公開 (#1110): 全体公開への発展導線。 */} + {detail.isMine && detail.recommended && detail.visibility === 'limited' ? ( +

+ +

+ ) : null}
+ {detail.isMine && detail.visibility === 'limited' && detail.status === 'published' && onOpenBroaden ? ( + + ) : null} {detail.isMine ? ( detail.status === 'published' ? (
- {shared.sharedDetail ? ( + {shared.broadenTarget ? ( + // 限定公開 → 全体公開に広げる (#1110): 共有フォームを編集モード + // (既存メタデータが初期値・classroomId なし) で出す。全体公開に + // 必要な CC BY 同意はフォーム側で必須。 + + } + initialValues={shared.broadenTarget} + isLoading={busy} + submitLabel={ + + } + onCancel={shared.handleCloseBroaden} + onShare={shared.handleBroadenShared} + /> + ) : shared.sharedDetail ? ( { reportSent={shared.reportSent} onClose={shared.handleCloseSharedDetail} onImport={shared.handleImportShared} + onOpenBroaden={shared.handleOpenBroaden} onReport={shared.handleReportShared} onSetStatus={shared.handleSetSharedStatus} /> ) : ( + {shared.broadenDone ? ( +

+ +

+ ) : null} {shared.catalogTab === 'all' ? (