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
2 changes: 1 addition & 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): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)
2. **クラス・課題**(S4 #1084): 全クラス検索(参加コード完全一致・名前部分一致/ 詳細(参加・提出カウント)/ アーカイブ切替 / **期限切れクラスの復元**(ddb-archive スナップショット検索 → dry-run プラン → 実行。EPIC #1049 の CLI `bin/restore-classroom.ts` の UI 後継)
2. **クラス・課題**(S4 #1084 + 俯瞰ダッシュボード): 3 タブ構成 — ①**俯瞰ダッシュボード**(作成の推移・内容の充実度・テーマ傾向 + みんなの課題の**有益候補**を見える化。有益候補を先生に促す仕組みは別 EPIC #1106)②**クラス検索**(参加コード完全一致・名前部分一致 / 詳細 / アーカイブ切替)③**期限切れ復元**(ddb-archive スナップショットを削除時期・先生でファセット絞り込み → dry-run → 実行。EPIC #1049 の CLI の UI 後継)
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**(`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる

## 認証・認可モデル(要点)
Expand Down
186 changes: 186 additions & 0 deletions infra/smalruby-admin/lambda/classroom-overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Pure aggregation for the クラス・課題 俯瞰ダッシュボード (Admin, EPIC #1073).
*
* The operator wants to grasp "どんな課題がどれくらい作られているか" along three
* axes (chosen in the design interview): creation trend, content richness and
* theme tendency — NOT popularity/engagement. Kept pure so the whole shape is
* unit-tested without DynamoDB.
*/

/** A classroom row as stored (only the fields we aggregate on). */
export interface ClassroomRow {
classroomId?: unknown;
className?: unknown;
assignmentName?: unknown;
teacherSub?: unknown;
status?: unknown; // 'active' | 'archived'
createdAt?: unknown;
content?: { pages?: { text?: string; imageKey?: string }[]; starterKey?: string };
}

/** Quota rows reuse the classrooms key space; never count them. */
export function isRealClassroom(row: ClassroomRow): boolean {
return typeof row.classroomId === 'string' && !row.classroomId.includes('-quota#');
}

/**
* Content richness score (0-4): 1 for having any page, +1 for multiple pages,
* +1 for any page image, +1 for a starter project. Higher = more ready to
* share as-is to みんなの課題.
* @param row - classroom row
* @returns {score, pageCount, hasImages, hasStarter}
*/
export function richness(row: ClassroomRow): {
score: number; pageCount: number; hasImages: boolean; hasStarter: boolean;
} {
const pages = row.content?.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;
let score = 0;
if (pageCount >= 1) score += 1;
if (pageCount >= 2) score += 1;
if (hasImages) score += 1;
if (hasStarter) score += 1;
return { score, pageCount, hasImages, hasStarter };
}

/** Split a Japanese/English title into rough keyword tokens for tallying. */
export function tokenizeTitle(title: string): string[] {
return title
.normalize('NFKC')
.toLowerCase()
// split on whitespace, punctuation and common separators (keep JP/EN/digits)
.split(/[\s、。・,.\/|::;;\-—_()()「」『』[]\[\]{}!!??]+/u)
.map(t => t.trim())
.filter(t => t.length >= 2 && !/^\d+$/.test(t));
}

const MONTH_KEY = (iso: string): string => iso.slice(0, 7); // YYYY-MM

/**
* Build the whole overview payload from every classroom row.
* @param rows - raw classroom items (quota rows are filtered out here)
* @param sharedTitles - normalized assignment titles already on みんなの課題
* (to flag "likely already shared" candidates); pass [] if unknown
* @param nowMs - current time (injectable for deterministic tests)
* @returns the dashboard aggregation
*/
export function buildOverview(
rows: ClassroomRow[],
sharedTitles: string[],
nowMs: number,
): {
summary: { total: number; active: number; archived: number; recent30d: number };
creationTrend: { month: string; count: number }[];
richnessDistribution: { score: number; count: number }[];
candidates: {
classroomId: string; className: string; assignmentName: string;
teacherSub: string; score: number; pageCount: number;
hasImages: boolean; hasStarter: boolean; createdAt: string; likelyShared: boolean;
}[];
themeKeywords: { keyword: string; count: number }[];
} {
const classrooms = rows.filter(isRealClassroom);
const sharedSet = new Set(sharedTitles.map(t => t.normalize('NFKC').trim().toLowerCase()));
const cutoff30 = nowMs - 30 * 24 * 60 * 60 * 1000;

let active = 0;
let archived = 0;
let recent30d = 0;
const byMonth = new Map<string, number>();
const byScore = new Map<number, number>();
const keywordCount = new Map<string, number>();
const candidates: ReturnType<typeof buildOverview>['candidates'] = [];

for (const row of classrooms) {
const status = String(row.status || '');
if (status === 'archived') archived += 1; else active += 1;

const createdAt = typeof row.createdAt === 'string' ? row.createdAt : '';
if (createdAt) {
byMonth.set(MONTH_KEY(createdAt), (byMonth.get(MONTH_KEY(createdAt)) || 0) + 1);
if (Date.parse(createdAt) >= cutoff30) recent30d += 1;
}

const r = richness(row);
byScore.set(r.score, (byScore.get(r.score) || 0) + 1);

const assignmentName = typeof row.assignmentName === 'string' ? row.assignmentName : '';
const className = typeof row.className === 'string' ? row.className : '';
for (const token of [...tokenizeTitle(assignmentName), ...tokenizeTitle(className)]) {
keywordCount.set(token, (keywordCount.get(token) || 0) + 1);
}

// 有益候補: reasonably rich (>=3) content. Flag likely-already-shared so
// the operator focuses on the unshared ones.
if (r.score >= 3) {
const likelyShared = sharedSet.has(assignmentName.normalize('NFKC').trim().toLowerCase());
candidates.push({
classroomId: String(row.classroomId),
className,
assignmentName,
teacherSub: typeof row.teacherSub === 'string' ? row.teacherSub : '',
score: r.score,
pageCount: r.pageCount,
hasImages: r.hasImages,
hasStarter: r.hasStarter,
createdAt,
likelyShared,
});
}
}

const creationTrend = [...byMonth.entries()]
.map(([month, count]) => ({ month, count }))
.sort((a, b) => a.month.localeCompare(b.month));

const richnessDistribution = [0, 1, 2, 3, 4]
.map(score => ({ score, count: byScore.get(score) || 0 }));

// Unshared candidates first, then richer, then newer.
candidates.sort((a, b) =>
Number(a.likelyShared) - Number(b.likelyShared) ||
b.score - a.score ||
b.createdAt.localeCompare(a.createdAt));

const themeKeywords = [...keywordCount.entries()]
.map(([keyword, count]) => ({ keyword, count }))
.filter(k => k.count >= 2)
.sort((a, b) => b.count - a.count || a.keyword.localeCompare(b.keyword))
.slice(0, 30);

return {
summary: { total: classrooms.length, active, archived, recent30d },
creationTrend,
richnessDistribution,
candidates: candidates.slice(0, 50),
themeKeywords,
};
}

/**
* Facet buckets for the restore tab (削除済みスナップショット). Groups a large
* candidate list by deletion month and by teacher so the operator can narrow
* down like the みんなの課題 catalog.
* @param snapshots - matched snapshot summaries
* @returns facet counts
*/
export function buildRestoreFacets(
snapshots: { deletedAt?: string | null; teacherSub?: string }[],
): { byMonth: { month: string; count: number }[]; byTeacher: { teacherSub: string; count: number }[] } {
const byMonth = new Map<string, number>();
const byTeacher = new Map<string, number>();
for (const s of snapshots) {
const month = s.deletedAt ? s.deletedAt.slice(0, 7) : '(不明)';
byMonth.set(month, (byMonth.get(month) || 0) + 1);
const t = s.teacherSub || '(不明)';
byTeacher.set(t, (byTeacher.get(t) || 0) + 1);
}
return {
byMonth: [...byMonth.entries()].map(([month, count]) => ({ month, count }))
.sort((a, b) => b.month.localeCompare(a.month)),
byTeacher: [...byTeacher.entries()].map(([teacherSub, count]) => ({ teacherSub, count }))
.sort((a, b) => b.count - a.count),
};
}
59 changes: 50 additions & 9 deletions infra/smalruby-admin/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { OAuth2Client } from 'google-auth-library';
import {
buildRestorePlan, matchSnapshot, PlannedItem, RestorePlanInput, Snapshot,
} from './restore-plan';
import { buildOverview, buildRestoreFacets, ClassroomRow } from './classroom-overview';

// --- Configuration ---

Expand Down Expand Up @@ -416,6 +417,22 @@ async function handleListClassrooms(
return { statusCode: 200, body: JSON.stringify({ items: items.slice(0, 100) }) };
}

async function handleClassroomOverview(
identity: AdminIdentity,
): Promise<APIGatewayProxyStructuredResultV2> {
audit('classroom.overview', identity, {});
const [classroomRows, sharedRows] = await Promise.all([
scanAll(CLASSROOMS_TABLE),
// Titles already on みんなの課題 — flag likely-shared candidates.
scanAll(SHARED_ASSIGNMENTS_TABLE).catch(() => [] as Record<string, unknown>[]),
]);
const sharedTitles = sharedRows
.map(r => (typeof r.title === 'string' ? r.title : ''))
.filter(Boolean);
const overview = buildOverview(classroomRows as ClassroomRow[], sharedTitles, Date.now());
return { statusCode: 200, body: JSON.stringify(overview) };
}

async function handleGetClassroom(
identity: AdminIdentity, classroomId: string,
): Promise<APIGatewayProxyStructuredResultV2> {
Expand Down Expand Up @@ -522,24 +539,45 @@ async function collectSnapshotChildren(kind: string, classroomId: string): Promi
async function handleSearchRestoreCandidates(
identity: AdminIdentity, query: Record<string, string | undefined>,
): Promise<APIGatewayProxyStructuredResultV2> {
// q is now optional: the operator can browse ALL deleted snapshots and
// narrow with facets (削除時期 / 先生) instead of only text search, because
// the raw list is too large to eyeball (みんなの課題 catalog style).
const q = (query.q || '').trim();
if (!q) {
throw new ValidationError('q is required');
}
audit('classroom.searchSnapshots', identity, { q });
const monthFilter = (query.month || '').trim();
const teacherFilter = (query.teacher || '').trim();
audit('classroom.searchSnapshots', identity, { q: q || null, month: monthFilter || null });

const keys = await listArchiveKeys(`${ARCHIVE_PREFIX}/classrooms/`);
const matches: Record<string, unknown>[] = [];
const all: { item: Record<string, unknown>; deletedAt: string | null; teacherSub: string }[] = [];
for (const key of keys) {
const snapshot = await readSnapshot(key);
if (snapshot && matchSnapshot(snapshot, q)) {
matches.push({
...mapClassroomForAdmin(snapshot.item),
if (snapshot?.item) {
all.push({
item: snapshot.item,
deletedAt: snapshot.deletedAt,
teacherSub: typeof snapshot.item.teacherSub === 'string' ? snapshot.item.teacherSub : '',
});
}
}
return { statusCode: 200, body: JSON.stringify({ items: matches }) };

// Facets are computed over everything (so the operator sees the full shape),
// the item list is what survives the active filters.
const facets = buildRestoreFacets(all);
const matches = all
.filter(s => !q || matchSnapshot({ table: 'classrooms', deletedAt: s.deletedAt, eventId: null, item: s.item }, q))
.filter(s => !monthFilter || (s.deletedAt || '').slice(0, 7) === monthFilter)
.filter(s => !teacherFilter || s.teacherSub === teacherFilter)
.map(s => ({
...mapClassroomForAdmin(s.item),
teacherSub: s.teacherSub,
deletedAt: s.deletedAt,
}))
.sort((a, b) => String(b.deletedAt || '').localeCompare(String(a.deletedAt || '')));

return {
statusCode: 200,
body: JSON.stringify({ items: matches.slice(0, 200), total: matches.length, facets }),
};
}

async function gatherRestoreInput(classroomId: string): Promise<{
Expand Down Expand Up @@ -711,6 +749,9 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
const sharedId = event.pathParameters?.sharedId || '';
result = await handleSetSharedStatus(identity, sharedId, body);

} else if (method === 'GET' && path === '/admin/classrooms/overview') {
result = await handleClassroomOverview(identity);

} else if (method === 'GET' && path === '/admin/classrooms') {
result = await handleListClassrooms(identity, event.queryStringParameters || {});

Expand Down
99 changes: 99 additions & 0 deletions infra/smalruby-admin/lambda/tests/classroom-overview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
buildOverview, buildRestoreFacets, isRealClassroom, richness, tokenizeTitle,
} from '../classroom-overview';

const NOW = Date.parse('2026-07-19T00:00:00.000Z');

const rows = [
// rich + recent + unshared → top candidate
{
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' },
},
// 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' },
},
// 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' }] },
},
// quota row → excluded from everything
{ classroomId: 'eval-quota#t1#2026-07-19', status: 'active', createdAt: '2026-07-19T00:00:00.000Z' },
];

describe('richness / tokenizeTitle / isRealClassroom', () => {
test('richness scores pages, images and starter', () => {
expect(richness(rows[0]).score).toBe(4); // >=2 pages(2) + image(1) + starter(1)
expect(richness(rows[2]).score).toBe(1); // 1 page only
expect(richness({}).score).toBe(0);
});

test('tokenizeTitle drops short tokens and pure numbers', () => {
const t = tokenizeTitle('ねこ迷路ゲーム 2026');
expect(t).toContain('ねこ迷路ゲーム');
expect(t).not.toContain('2026');
});

test('quota rows are not real classrooms', () => {
expect(isRealClassroom(rows[3])).toBe(false);
expect(isRealClassroom(rows[0])).toBe(true);
});
});

describe('buildOverview (issue #1106 dashboard)', () => {
const o = buildOverview(rows, ['ねこあつめ入門'], NOW);

test('summary counts exclude quota rows and split active/archived', () => {
expect(o.summary.total).toBe(3);
expect(o.summary.active).toBe(2);
expect(o.summary.archived).toBe(1);
expect(o.summary.recent30d).toBe(2); // c1 and c3 within 30d of NOW
});

test('creation trend is per-month, ascending', () => {
expect(o.creationTrend).toEqual([
{ month: '2026-06', count: 1 },
{ month: '2026-07', count: 2 },
]);
});

test('richness distribution covers scores 0-4', () => {
expect(o.richnessDistribution).toHaveLength(5);
expect(o.richnessDistribution.find(d => d.score === 4)?.count).toBe(2);
});

test('candidates: unshared rich ones first, shared flagged, thin excluded', () => {
expect(o.candidates.map(c => c.classroomId)).toEqual(['c1', 'c2']);
expect(o.candidates[0].likelyShared).toBe(false);
expect(o.candidates[1].likelyShared).toBe(true); // ねこあつめ入門 already shared
expect(o.candidates.some(c => c.classroomId === 'c3')).toBe(false);
});

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);
});
});

describe('buildRestoreFacets (issue #1084 restore facets)', () => {
test('groups snapshots by deletion month and teacher', () => {
const f = buildRestoreFacets([
{ deletedAt: '2026-07-10T00:00:00Z', teacherSub: 't1' },
{ deletedAt: '2026-07-11T00:00:00Z', teacherSub: 't1' },
{ deletedAt: '2026-06-01T00:00:00Z', teacherSub: 't2' },
{ deletedAt: null, teacherSub: '' },
]);
expect(f.byMonth).toEqual([
{ month: '2026-07', count: 2 },
{ month: '2026-06', count: 1 },
{ month: '(不明)', count: 1 },
]);
expect(f.byTeacher[0]).toEqual({ teacherSub: 't1', count: 2 });
});
});
Loading
Loading