From 0e4846cf72bab18b695bf82b3c2e223ec21030b8 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:38:18 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(admin,classroom):=20=E8=AA=B2=E9=A1=8C?= =?UTF-8?q?=E3=81=AE=E5=85=B1=E6=9C=89=E6=8E=A8=E5=A5=A8=E3=83=95=E3=83=A9?= =?UTF-8?q?=E3=82=B0API=E3=81=A8=E6=9C=89=E7=9B=8A=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3=20(#1106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin API に POST/DELETE /admin/classrooms/{id}/recommend-sharing を 追加。推奨時は recommendedForSharingAt/By を Classrooms 項目に記録し、 作成した先生へお知らせ (#1111・type 'share_suggestion'・link kind 'classroom') を送る。先生側 API には boolean の recommendedForSharing だけを投影。 俯瞰ダッシュボードの有益候補が常に空になるバグを修正: classroom-overview.ts が課題コンテンツを row.content で読んでいたが、 classroom item の実フィールド名は assignment (content は SharedAssignments 側の名前)。実データに対して richness が常に 0 → score>=3 の候補ゼロだった。回帰テストでフィールド名を pin。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D19 フラグの置き場所: Classrooms 項目に直接 (課題=Classroom レコード で 1:1、admin は既に RW grant 保有)。代替案: assignment 属性の中 (課題コンテンツの編集で上書きされるリスクがあり不採用) - D20 通知文: 課題一覧の「共有」ボタンへの操作誘導を本文に含める (バナーに気付かなくても通知だけで動線が分かる) - #1110 レビューで確立した「通知→印付け・条件式で冪等原子化・ 取り消しは静か」の形を踏襲 - 対象は active のみ (アーカイブ済みは先生から見えないため 404) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lambda/classroom-overview.ts | 13 +- infra/smalruby-admin/lambda/handler.ts | 93 ++++++++++ .../lambda/tests/classroom-overview.test.ts | 16 +- .../tests/handler-recommend-sharing.test.ts | 161 ++++++++++++++++++ infra/smalruby-admin/lib/admin-stack.ts | 3 + infra/smalruby-classroom/lambda/handler.ts | 4 + 6 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts 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..a1c1a7a9985 100644 --- a/infra/smalruby-admin/lambda/handler.ts +++ b/infra/smalruby-admin/lambda/handler.ts @@ -504,9 +504,94 @@ 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; archived rows are invisible to teachers. + if (!item || String(classroomId).includes('-quota#') || item.status !== 'active') { + throw new NotFoundError('Classroom not found'); + } + const teacherSub = typeof item.teacherSub === 'string' ? item.teacherSub : ''; + if (!teacherSub) { + throw new NotFoundError('Classroom not found'); + } + + const alreadyRecommended = !!item.recommendedForSharingAt; + if (recommended && !alreadyRecommended) { + const now = new Date().toISOString(); + // #1110 と同じ「通知 → 印付け」の順 + 原子的な冪等判定(印付け後の通知 + // 失敗は回復不能になる / 同時 POST の二重通知防止)。 + 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 +1058,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..d75dc7d9eb4 --- /dev/null +++ b/infra/smalruby-admin/lambda/tests/handler-recommend-sharing.test.ts @@ -0,0 +1,161 @@ +/** + * 共有推奨 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, + 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('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/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', }), }; From f909c12f06b5575e7977bd3d405592ca8678ca2c 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:40:15 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(admin):=20=E3=82=AF=E3=83=A9=E3=82=B9?= =?UTF-8?q?=E8=A9=B3=E7=B4=B0=E3=81=AB=E5=85=B1=E6=9C=89=E6=8E=A8=E5=A5=A8?= =?UTF-8?q?=E3=82=A2=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=BB=E4=BF=AF?= =?UTF-8?q?=E7=9E=B0=E5=80=99=E8=A3=9C=E3=81=AB=E6=8E=A8=E5=A5=A8=E6=B8=88?= =?UTF-8?q?=E3=81=BF=E3=83=90=E3=83=83=E3=82=B8=20(#1106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クラス詳細 (俯瞰候補クリック/クラス検索から到達) に「みんなの課題への 共有を推奨する / 取り消す」を二段階確認で追加し、推奨中バッジを表示。 俯瞰ダッシュボードの有益候補行にも「推奨済み」バッジを出して、 どの候補に働きかけ済みかが一目で分かるようにした。 設計上の分岐点: - D21 操作の置き場所: 候補行に直接ボタンを置かず、詳細画面に集約 (#1111 の D6 と同じ「対象の文脈を確認してから操作」原則)。 俯瞰候補→クリック→詳細→推奨、の 3 クリック動線 - 確認文言に「お知らせが届き、課題にバナーが出ます」を明記 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/classroom-overview-view.jsx | 6 ++ .../admin/src/components/classrooms-view.jsx | 78 ++++++++++++++++++- packages/admin/src/lib/admin-api.js | 14 +++- .../admin/test/unit/classrooms-view.test.jsx | 39 +++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) 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(); From ea3f01f040d1061271111b91e5886b09824a4d4e 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:46:21 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(gui):=20=E8=AA=B2=E9=A1=8C=E8=A9=B3?= =?UTF-8?q?=E7=B4=B0=E3=81=AB=E5=85=B1=E6=9C=89=E4=BF=83=E3=81=97=E3=83=90?= =?UTF-8?q?=E3=83=8A=E3=83=BC=E3=83=BB=E3=83=9C=E3=83=BC=E3=83=89=E8=A1=8C?= =?UTF-8?q?=E3=81=AB=E5=85=B1=E6=9C=89=E3=81=8A=E3=81=99=E3=81=99=E3=82=81?= =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=AF=20(#1106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommendedForSharing が立った課題の詳細上部に「この課題、みんなの 課題に共有しませんか?」バナーを表示。「共有フォームを開く」CTA は ボードへ戻って既存の共有ステップ (teacher-share-step) を開く。 課題一覧 (ボード) の該当行には「共有おすすめ」マークを表示。 設計上の分岐点 (このコミットに立ち戻ればやり直せる): - D22 CTA の挙動: 共有導線はボード行に一本化済み (#1109) のため、 新しい共有 UI を詳細に作らず onBackToDashboard + handleOpenShareFor の合成でボードの共有ステップへ誘導。代替案: 詳細内に共有フォームを 埋め込む (#1109 の一本化を崩すため不採用) - D23 バナーの解除: 先生側から消す操作は付けない (推奨の取り下げは admin の責務。先生が共有すれば admin が俯瞰で気付いて取り消せる) - 通知 (share_suggestion・link.kind='classroom') は既存のジャンプ処理 で課題詳細に直行し、そこにバナーが出る構成 検証: jest 24件 + headless Playwright 4/4 (通知→バナー→共有ステップ →ボードのマーク) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../classroom-modal/classroom-modal.css | 46 +++++++++++++++++++ .../teacher-assignment-board.jsx | 13 ++++++ .../classroom-modal/teacher-class-detail.jsx | 32 +++++++++++++ .../classroom-teacher-modal.jsx | 11 ++++- packages/scratch-gui/src/locales/ja-Hira.js | 4 ++ packages/scratch-gui/src/locales/ja.js | 4 ++ .../teacher-assignment-board.test.jsx | 11 +++++ .../components/teacher-class-detail.test.jsx | 19 ++++++++ 8 files changed, 139 insertions(+), 1 deletion(-) 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}