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 @@ -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 に貼り付けられる

Expand Down Expand Up @@ -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) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/assignment-sharing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/classroom/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 サーバー側コピー(クロスバケット)
Expand Down
4 changes: 4 additions & 0 deletions docs/classroom/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | パンくずリンク |
Expand Down
115 changes: 113 additions & 2 deletions infra/smalruby-admin/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ function mapSharedItemForAdmin(item: Record<string, unknown>) {
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,
};
Expand Down Expand Up @@ -299,11 +305,17 @@ async function handleListSharedReports(identity: AdminIdentity): Promise<APIGate
async function handleListSharedAssignments(
identity: AdminIdentity, query: Record<string, string | undefined>,
): Promise<APIGatewayProxyStructuredResultV2> {
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 =>
Expand Down Expand Up @@ -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<APIGatewayProxyStructuredResultV2> {
const result = await docClient.send(new GetCommand({
TableName: SHARED_ASSIGNMENTS_TABLE,
Key: { sharedId },
}));
const item = result.Item as Record<string, unknown> | 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -844,6 +947,14 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
} else if (method === 'GET' && path === '/admin/shared-assignments') {
result = await handleListSharedAssignments(identity, event.queryStringParameters || {});

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

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

} else if (method === 'GET' && /^\/admin\/shared-assignments\/[^/]+$/.test(path)) {
const sharedId = event.pathParameters?.sharedId || '';
result = await handleGetSharedAssignment(identity, sharedId);
Expand Down
Loading
Loading