Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
### セクション(3 ドメイン)

1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / **限定公開タブ**(推薦候補の母集団・#1110)/ 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)/ **推薦する・推薦を取り消す**(#1110。2 段階確認・audit。推薦すると作成した先生のお知らせセンター #1111 に通知が届き、限定公開を全体公開に広げる動線につながる。取り消しは通知なし)
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)。クラス詳細には**先生へのお知らせ送信**(EPIC #1111)があり、タイトル + 本文を二段階確認で `POST /admin/notifications` へ送ると、その課題を作成した先生のクラス管理画面右上「お知らせ」🔔 に届く(宛先 teacherSub はサーバー側で classroomId から解決し SPA には出さない。書き込み先 `ClassroomNotifications` テーブルは名前規約 import + write-only grant で、この面が単一の書き手)
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。候補行には**推奨済みバッジ**)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)。クラス詳細には**先生へのお知らせ送信**(EPIC #1111)があり、タイトル + 本文を二段階確認で `POST /admin/notifications` へ送ると、その課題を作成した先生のクラス管理画面右上「お知らせ」🔔 に届く(宛先 teacherSub はサーバー側で classroomId から解決し SPA には出さない。書き込み先 `ClassroomNotifications` テーブルは名前規約 import + write-only grant で、この面が単一の書き手)。さらに**みんなの課題への共有推奨**(EPIC #1106)があり、二段階確認で `POST /admin/classrooms/{id}/recommend-sharing` を実行すると先生へお知らせ(`share_suggestion`)が届き、先生の課題詳細に「この課題、みんなの課題に共有しませんか?」バナーが出る(公開は CC BY 同意を伴う先生本人の共有フローのみ — 代理公開はしない。取り消しは通知なし・audit のみ
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる

## 認証・認可モデル(要点)
Expand Down Expand Up @@ -92,3 +92,4 @@ cd tools/playwright-verify && node verify-admin.mjs
| `screenshots/0105-bug-reports.png` | バグ報告: 閲覧一覧 |
| `screenshots/0106-classroom-notify.png` | クラス詳細: 先生へのお知らせ送信(EPIC #1111) |
| `screenshots/0107-shared-recommend.png` | みんなの課題: 限定公開の詳細と推薦の確認(EPIC #1110) |
| `screenshots/0108-recommend-sharing.png` | クラス詳細: 共有推奨の確認(EPIC #1106) |
Binary file added docs/admin/screenshots/0108-recommend-sharing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/classroom/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ erDiagram
string googleClassroomCourseId "任意"
list coTeacherEmails "共同管理者の email 配列 (任意, 最大10)"
map assignment "課題コンテンツ (任意): {pages: [{text, imageKey?}], starterKey?, updatedAt}"
string recommendedForSharingAt "共有推奨 (#1106, 任意)。書き込みは admin API のみ。先生側 API には boolean recommendedForSharing を投影"
string recommendedForSharingBy "推奨した admin の email (内部用・先生側 API では返さない)"
string groupId "所属する組 (任意)"
string status "active / archived"
string createdAt "ISO8601"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/classroom/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ Playwright MCP および Selenium integration tests で使用する `data-testid
| `shared-detail-broaden` | button | 「みんなの課題に公開する」(自分の限定公開のみ・#1110) |
| `shared-broaden-done` | p | 全体公開の完了メッセージ(#1110) |
| `shared-import-success` | p | 取り込み完了メッセージ(ボード上) |
| `classroom-share-suggestion-banner` / `classroom-share-suggestion-open` | div/button | 共有推奨バナーと「共有フォームを開く」CTA(#1106) |
| `classroom-board-share-suggested-{classroomId}` | span | ボード行の「共有おすすめ」マーク(#1106) |
| `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) |
| `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク |
| `classroom-board-create-name` / `classroom-board-create-submit` | input / button | インライン課題作成(課題名のみ) |
Expand Down
16 changes: 16 additions & 0 deletions docs/classroom/ui-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ Google または Microsoft アカウントでサインインする画面。先
| 未読ドット | `classroom-notification-unread-dot` | 未読アイテムのみ |
| 空メッセージ | `classroom-notifications-empty` | お知らせ 0 件のとき |

## 1.9 先生: 共有推奨バナー(#1106)

![共有推奨バナー](screenshots/0219-share-suggestion-banner.png)

運営(Admin)が「みんなの課題に共有する価値がある」と判断した課題には、課題詳細の上部に「**この課題、みんなの課題に共有しませんか?**」バナーが出る。「共有フォームを開く」でボードの共有ステップ(既存の共有フロー)が開く。公開はあくまで CC BY 同意を伴う**先生本人の共有操作のみ**(運営による代理公開はしない)。

- 推奨と同時に運営からのお知らせ(🔔・`share_suggestion`)も届き、クリックでこの課題の詳細へジャンプする
- 課題一覧(ボード)の該当行には「**共有おすすめ**」マークが付く
- フラグは admin が取り消すまで表示される(先生側から消す操作は無い)

| 要素 | data-testid | 操作 |
|------|-------------|------|
| バナー | `classroom-share-suggestion-banner` | — |
| 共有フォームを開く | `classroom-share-suggestion-open` | ボードへ戻って共有ステップを開く |
| ボード行のマーク | `classroom-board-share-suggested-{classroomId}` | — |

## 4. 先生: クラス詳細 (`teacher-detail`)

クラスの参加状況と提出を管理する画面。モーダルが**ワイド表示 (968px)** に広がります。
Expand Down
13 changes: 10 additions & 3 deletions infra/smalruby-admin/lambda/classroom-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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 }[];
} {
Expand Down Expand Up @@ -127,6 +133,7 @@ export function buildOverview(
hasStarter: r.hasStarter,
createdAt,
likelyShared,
recommendedForSharing: !!row.recommendedForSharingAt,
});
}
}
Expand Down
109 changes: 109 additions & 0 deletions infra/smalruby-admin/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,110 @@ function mapClassroomForAdmin(item: Record<string, unknown>) {
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<APIGatewayProxyStructuredResultV2> {
const result = await docClient.send(new GetCommand({
TableName: CLASSROOMS_TABLE,
Key: { classroomId },
}));
const item = result.Item as Record<string, unknown> | undefined;
// Quota rows share the key space.
if (!item || String(classroomId).includes('-quota#')) {
throw new NotFoundError('Classroom not found');
}
const teacherSub = typeof item.teacherSub === 'string' ? item.teacherSub : '';
if (!teacherSub) {
throw new NotFoundError('Classroom not found');
}
// アーカイブ済みは先生から見えないので新規の推奨はしない。ただし取り消し
// (下) は許す — 推奨後にアーカイブされるとフラグが運営から触れなくなり、
// 復元時に古いバナーが再出現するため(レビュー指摘)。
if (recommended && item.status !== 'active') {
throw new NotFoundError('Classroom not found');
}
// 中身(説明ページ or スターター)が無い課題は共有 API が拒否するので、
// 「共有しませんか?」と促しても行き止まりになる — 推奨自体を拒否する
// (レビュー指摘)。
const assignment = item.assignment as { pages?: unknown[]; starterKey?: string } | undefined;
const hasAssignmentContent = !!assignment &&
((Array.isArray(assignment.pages) && assignment.pages.length > 0) || !!assignment.starterKey);
if (recommended && !hasAssignmentContent) {
throw new ValidationError('Classroom has no assignment content to share');
}

const alreadyRecommended = !!item.recommendedForSharingAt;
if (recommended && !alreadyRecommended) {
const now = new Date().toISOString();
// #1110 と同じ「通知 → 印付け」の順 + 印付けの冪等を原子化。真に同時の
// POST では通知が重複しうる(SPA の busy 無効化で実質防止・最悪ケースは
// 重複通知で無害 — #1110 と同じ割り切り)。
const title = String(item.assignmentName || item.className || '課題');
await putNotification(teacherSub, {
type: 'share_suggestion',
title: 'この課題、みんなの課題に共有しませんか?',
body: `「${title}」が内容の充実した課題として運営のおすすめに選ばれました。課題一覧の「共有」から、全国の先生に共有できます。`,
link: { kind: 'classroom', classroomId },
createdBy: identity.email,
});
try {
await docClient.send(new UpdateCommand({
TableName: CLASSROOMS_TABLE,
Key: { classroomId },
UpdateExpression:
'SET recommendedForSharingAt = :now, recommendedForSharingBy = :email, updatedAt = :now',
ConditionExpression: 'attribute_not_exists(recommendedForSharingAt)',
ExpressionAttributeValues: { ':now': now, ':email': identity.email },
}));
} catch (err) {
if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err;
}
audit('classroom.recommendSharing', identity, { classroomId });
return {
statusCode: 200,
body: JSON.stringify(mapClassroomForAdmin({
...item, recommendedForSharingAt: now, recommendedForSharingBy: identity.email,
})),
};
}

if (!recommended && alreadyRecommended) {
await docClient.send(new UpdateCommand({
TableName: CLASSROOMS_TABLE,
Key: { classroomId },
UpdateExpression: 'REMOVE recommendedForSharingAt, recommendedForSharingBy SET updatedAt = :now',
ExpressionAttributeValues: { ':now': new Date().toISOString() },
}));
// 取り消しは通知しない(先生を騒がせない)。audit で追跡できる。
audit('classroom.unrecommendSharing', identity, { classroomId });
return {
statusCode: 200,
body: JSON.stringify(mapClassroomForAdmin({
...item, recommendedForSharingAt: undefined, recommendedForSharingBy: undefined,
})),
};
}

// No-op (already in the requested state) — idempotent success.
return { statusCode: 200, body: JSON.stringify(mapClassroomForAdmin(item)) };
}

async function handleListClassrooms(
identity: AdminIdentity, query: Record<string, string | undefined>,
): Promise<APIGatewayProxyStructuredResultV2> {
Expand Down Expand Up @@ -973,6 +1074,14 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
} else if (method === 'GET' && path === '/admin/classrooms/restore-candidates') {
result = await handleSearchRestoreCandidates(identity, event.queryStringParameters || {});

} else if (method === 'POST' && /^\/admin\/classrooms\/[^/]+\/recommend-sharing$/.test(path)) {
const classroomId = event.pathParameters?.classroomId || '';
result = await handleSetSharingRecommendation(identity, classroomId, true);

} else if (method === 'DELETE' && /^\/admin\/classrooms\/[^/]+\/recommend-sharing$/.test(path)) {
const classroomId = event.pathParameters?.classroomId || '';
result = await handleSetSharingRecommendation(identity, classroomId, false);

} else if (method === 'GET' && /^\/admin\/classrooms\/[^/]+\/restore-plan$/.test(path)) {
const classroomId = event.pathParameters?.classroomId || '';
result = await handleRestorePlan(identity, classroomId);
Expand Down
16 changes: 13 additions & 3 deletions infra/smalruby-admin/lambda/tests/classroom-overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,20 @@ const rows = [
{
classroomId: 'c1', className: '5年1組', assignmentName: 'ねこ迷路ゲーム', teacherSub: 't1',
status: 'active', createdAt: '2026-07-10T00:00:00.000Z',
content: { pages: [{ text: 'a', imageKey: 'k1' }, { text: 'b' }], starterKey: 's1' },
assignment: { pages: [{ text: 'a', imageKey: 'k1' }, { text: 'b' }], starterKey: 's1' },
recommendedForSharingAt: '2026-07-15T00:00:00.000Z',
},
// rich but already shared
{
classroomId: 'c2', className: '6年2組', assignmentName: 'ねこあつめ入門', teacherSub: 't2',
status: 'archived', createdAt: '2026-06-05T00:00:00.000Z',
content: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' },
assignment: { pages: [{ text: 'a', imageKey: 'k' }, { text: 'b' }], starterKey: 's' },
},
// thin content → not a candidate
{
classroomId: 'c3', className: '5年1組', assignmentName: 'ねこ体操', teacherSub: 't1',
status: 'active', createdAt: '2026-07-01T00:00:00.000Z',
content: { pages: [{ text: 'a' }] },
assignment: { pages: [{ text: 'a' }] },
},
// quota row → excluded from everything
{ classroomId: 'eval-quota#t1#2026-07-19', status: 'active', createdAt: '2026-07-19T00:00:00.000Z' },
Expand Down Expand Up @@ -75,6 +76,15 @@ describe('buildOverview (issue #1106 dashboard)', () => {
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);
Expand Down
Loading
Loading