From 33eaf0b15f341f1acbde6f43c5bafc20674b2283 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:56:11 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(admin,classroom):=20=E5=85=B1=E6=9C=89?= =?UTF-8?q?=E8=AA=B2=E9=A1=8C=E3=81=AE=20Admin=20=E6=8E=A8=E8=96=A6API?= =?UTF-8?q?=E3=81=A8=E5=85=88=E7=94=9F=E5=90=91=E3=81=91=20recommended=20?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=20(#1110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin API に POST/DELETE /admin/shared-assignments/{id}/recommend を追加。 推薦時は recommendedAt/recommendedBy を記録し、著者の先生へお知らせ センター (#1111) 経由で通知(link.kind='shared-mine')。取り消しは 静かに解除(audit のみ)。一覧に visibility フィルタを追加。 先生側 API (mapSharedSummary) には boolean の recommended だけを投影。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D11 推薦の保存先: SharedAssignments 項目に直接 recommendedAt/By を 持たせた (admin は既に同テーブルのモデレーション書込 grant を保有)。 代替案: 別テーブル (結合が増えるだけなので不採用) - D12 対象: status='published' なら visibility を問わず推薦可 (EPIC の 主対象は限定公開だが、公開項目の推薦印も後続で意味を持ちうる)。 unlisted (取り下げ) は 404 - D13 通知: 推薦時のみ・冪等 (既推薦なら no-op で再通知しない)。 通知の put は best-effort にせず失敗を運営に見せる (通知が主目的) - D14 露出: 先生側には recommended (boolean) のみ。recommendedBy (admin email) は先生に出さない。admin にも passcode は出さない Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/smalruby-admin/lambda/handler.ts | 99 ++++++++- .../handler-shared-recommendation.test.ts | 188 ++++++++++++++++++ infra/smalruby-admin/lib/admin-stack.ts | 3 + infra/smalruby-classroom/lambda/handler.ts | 3 + 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 infra/smalruby-admin/lambda/tests/handler-shared-recommendation.test.ts diff --git a/infra/smalruby-admin/lambda/handler.ts b/infra/smalruby-admin/lambda/handler.ts index 8ea115a1fad..98840e60082 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,79 @@ 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) { + const now = new Date().toISOString(); + await docClient.send(new UpdateCommand({ + TableName: SHARED_ASSIGNMENTS_TABLE, + Key: { sharedId }, + UpdateExpression: 'SET recommendedAt = :now, recommendedBy = :email, updatedAt = :now', + ExpressionAttributeValues: { ':now': now, ':email': identity.email }, + })); + // 推薦の主目的は先生への働きかけ: お知らせセンター (#1111) へ通知する。 + // 通知は best-effort ではなく本体 — 失敗したらエラーにして運営が気付く。 + 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, + }); + } + 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 +594,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 +931,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 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'); + + 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('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..d70e5be5f73 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, From fa7587dfd20b1d129c604d21af3c7ed983022492 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:58:55 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(admin):=20=E9=99=90=E5=AE=9A=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E3=82=BF=E3=83=96=E3=81=A8=E6=8E=A8=E8=96=A6=E3=81=99?= =?UTF-8?q?=E3=82=8B/=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=99=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E3=82=92=E8=BF=BD=E5=8A=A0=20(#1110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit みんなの課題ビューに「限定公開」タブ (visibility フィルタ) を追加し、 一覧行と詳細に 限定公開/推薦中 バッジを表示。詳細に「推薦する/ 推薦を取り消す」を非公開化とは独立した二段階確認で追加。 設計上の分岐点: - D15 推薦候補の見せ方: 専用タブ (限定公開) を追加。すべての投稿に フィルタ UI を足す案より、運営の定常動線 (限定公開を巡回→推薦) が 1 クリックで出るタブを採用 - 推薦の確認文言に「先生にお知らせが届きます」を明記 (通知が飛ぶ 操作であることを操作時点で意識させる) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/shared-assignments-view.jsx | 110 +++++++++++++++++- packages/admin/src/lib/admin-api.js | 22 +++- .../unit/shared-assignments-view.test.jsx | 42 ++++++- 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/packages/admin/src/components/shared-assignments-view.jsx b/packages/admin/src/components/shared-assignments-view.jsx index aa1bac93484..81b556dd6ee 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' ? ( + confirmingRecommend ? ( + + {detail.recommended ? + '推薦を取り消しますか?(先生には通知されません)' : + 'この課題を推薦しますか?(作成した先生にお知らせが届きます)'} + + + + ) : ( + + ) + ) : null} ); @@ -181,7 +274,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 +291,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 +323,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')); From 7665198d671b660d1b9c0ca04ac8a2e18f06d592 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:06:06 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(gui):=20=E6=8E=A8=E8=96=A6=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=B8=E3=81=A8=E9=99=90=E5=AE=9A=E5=85=AC=E9=96=8B?= =?UTF-8?q?=E2=86=92=E5=85=A8=E4=BD=93=E5=85=AC=E9=96=8B=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E7=99=BA=E5=B1=95=E5=B0=8E=E7=B7=9A=20(#1110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit みんなの課題の「自分の投稿」カードに 限定公開/推薦 バッジを表示。 自分の限定公開の詳細に「みんなの課題に公開する」CTA を追加し、 共有フォームを編集モード (initialValues・classroomId なし) で再利用 して PATCH visibility='public' + CC BY 同意で全体公開に広げる。 推薦通知 (link.kind='shared-mine') のジャンプ先として「自分の投稿」 を直接開く handleOpenCatalogMine を追加。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D16 推薦印の場所: 「自分の投稿」一覧のカード (先生の共有課題の 一覧=ボード) に表示。クラスの課題ボード行への表示は共有項目が 元 classroomId を持たないため今回のスコープ外 (必要なら共有時に sourceClassroomId を保存する追加が要る) - D17 発展フォーム: 新規フォームを作らず SharedAssignmentForm を 編集モード対応に拡張して再利用 (バリデーション/文言が公開時と 完全一致し、二重管理を避ける)。CC BY 同意は編集モードでも必須 - D18 ジャンプの文脈: カタログはボード内サブビューのため、クラス 未選択時はアクティブな先頭クラスを開いてから表示 (専用フェーズの 新設はナビ構造の変更が大きく不採用) 検証: jest 22件 + headless Playwright 12/12 (通知ジャンプ→バッジ→ 発展フォーム→PATCH payload まで) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../classroom-modal/classroom-modal.css | 10 ++ .../shared-assignment-catalog.jsx | 83 +++++++++- .../shared-assignment-form.jsx | 61 +++++--- .../src/containers/use-shared-assignments.js | 65 ++++++++ .../src/containers/use-teacher-classroom.js | 12 +- packages/scratch-gui/src/locales/ja-Hira.js | 7 + packages/scratch-gui/src/locales/ja.js | 7 + .../shared-assignment-catalog.test.jsx | 57 +++++++ .../shared-assignment-form.test.jsx | 40 +++++ .../verify-shared-recommendation.mjs | 142 ++++++++++++++++++ 10 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 tools/playwright-verify/verify-shared-recommendation.mjs 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' ? (