Skip to content

Commit 2913579

Browse files
Merge pull request #1100 from smalruby/feature/admin-bug-report-write
feat(admin): バグ報告の状態変更と進捗コメントを可能にする
2 parents a994714 + ceab3da commit 2913579

6 files changed

Lines changed: 247 additions & 37 deletions

File tree

docs/admin/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)
2020
2. **クラス・課題**(S4 #1084): 全クラス検索(参加コード完全一致・名前部分一致)/ 詳細(参加・提出カウント)/ アーカイブ切替 / **期限切れクラスの復元**(ddb-archive スナップショット検索 → dry-run プラン → 実行。EPIC #1049 の CLI `bin/restore-classroom.ts` の UI 後継)
21-
3. **バグ報告**(S5 #1085): 既存バグ報告の**閲覧のみ**(一覧・状態フィルタ・詳細・添付 presigned DL)。状態変更・返信は従来の運用手段を継続
21+
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)
2222

2323
## 認証・認可モデル(要点)
2424

@@ -28,7 +28,7 @@
2828
- トークンは**モジュールメモリのみ**(localStorage に保存しない・N1)。リロード時は再ログイン
2929
- **セッション切れ(約 1 時間)**: API が 401 を返すと全画面の再読み込みプロンプトを表示。表示中のセクションは **URL ハッシュ**`#/classrooms` 等)に保持しているため、再読み込み → 再ログイン後に元のセクションへ復帰する(localStorage 不使用)
3030
- すべての管理操作は `audit()` 構造化ログ(prod の CloudWatch 保持は **1 年**
31-
- バグ報告の閲覧は bug-report 側の既存レジストリ `BugReportAdmins`(email)で認可される(両方への登録が必要)
31+
- バグ報告の閲覧・対応は bug-report 側の既存レジストリ `BugReportAdmins`(email)で認可される(両方への登録が必要)
3232

3333
詳細な登録手順・監査ログ検索・デプロイ手順は [operations.md](operations.md)
3434

@@ -43,7 +43,7 @@
4343
| `src/components/classrooms-view.jsx` | クラス・課題管理 + 期限切れ復元 |
4444
| `src/components/bug-reports-view.jsx` | バグ報告閲覧(read-only) |
4545
| `src/lib/admin-api.js` | admin API クライアント(トークンはモジュールメモリ) |
46-
| `src/lib/bug-report-api.js` | bug-report API クライアント(**GET 専用**|
46+
| `src/lib/bug-report-api.js` | bug-report API クライアント(一覧・詳細 + 状態/返信の PATCH。既存 API のみ使用|
4747
| `src/lib/google-auth.js` | GIS ロード + `?devlogin=` バイパス(stg のみ) |
4848
| `webpack.config.js` | 独立ビルド(publicPath `/admin/`、port 8602、DefinePlugin で endpoint 埋め込み) |
4949

packages/admin/src/components/app.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,32 @@ body {
250250
font-size: 1rem;
251251
cursor: pointer;
252252
}
253+
254+
.admin-respond {
255+
margin-top: 1.5rem;
256+
padding: 1rem;
257+
border: 1px solid hsl(220, 15%, 85%);
258+
border-radius: 0.5rem;
259+
background: hsl(220, 25%, 98%);
260+
}
261+
262+
.admin-respond h4 {
263+
margin: 0 0 0.5rem;
264+
}
265+
266+
.admin-respond textarea {
267+
display: block;
268+
width: 100%;
269+
max-width: 40rem;
270+
margin-top: 0.5rem;
271+
padding: 0.5rem;
272+
border: 1px solid hsl(220, 15%, 75%);
273+
border-radius: 0.4rem;
274+
font-size: 0.9rem;
275+
}
276+
277+
.admin-respond select {
278+
padding: 0.25rem 0.5rem;
279+
border: 1px solid hsl(220, 15%, 75%);
280+
border-radius: 0.4rem;
281+
}

packages/admin/src/components/bug-reports-view.jsx

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import PropTypes from 'prop-types';
99
import {useCallback, useEffect, useState} from 'react';
10-
import {fetchBugReport, fetchBugReports} from '../lib/bug-report-api.js';
10+
import {fetchBugReport, fetchBugReports, updateBugReport} from '../lib/bug-report-api.js';
1111

1212
const STATUS_LABELS = {
1313
open: '未対応',
@@ -44,17 +44,56 @@ const hideBrokenImage = event => {
4444
event.currentTarget.style.display = 'none';
4545
};
4646

47-
const BugReportDetail = ({reportId, onBack}) => {
47+
const BugReportDetail = ({reportId, onBack, onChanged}) => {
4848
const [detail, setDetail] = useState(null);
4949
const [error, setError] = useState('');
50+
const [nextStatus, setNextStatus] = useState('');
51+
const [reply, setReply] = useState('');
52+
const [confirming, setConfirming] = useState(false);
53+
const [busy, setBusy] = useState(false);
54+
const [savedAt, setSavedAt] = useState(null);
5055

5156
useEffect(() => {
5257
fetchBugReport(reportId)
53-
.then(setDetail)
58+
.then(data => {
59+
setDetail(data);
60+
setNextStatus(data.status);
61+
setReply(data.developerReply || '');
62+
})
5463
.catch(err => setError(err.message));
5564
}, [reportId]);
5665

57-
if (error) {
66+
const dirty = detail &&
67+
(nextStatus !== detail.status || reply !== (detail.developerReply || ''));
68+
const turnsTerminal = detail && nextStatus !== detail.status &&
69+
(nextStatus === 'resolved' || nextStatus === 'wont_fix');
70+
71+
const handleStatusChange = useCallback(e => setNextStatus(e.target.value), []);
72+
const handleReplyChange = useCallback(e => setReply(e.target.value), []);
73+
const handleArm = useCallback(() => setConfirming(true), []);
74+
const handleDisarm = useCallback(() => setConfirming(false), []);
75+
const handleSave = useCallback(async () => {
76+
if (!detail) return;
77+
setBusy(true);
78+
setError('');
79+
try {
80+
// Send only what changed — the API rejects an empty update.
81+
const updates = {};
82+
if (nextStatus !== detail.status) updates.status = nextStatus;
83+
if (reply !== (detail.developerReply || '')) updates.developerReply = reply;
84+
await updateBugReport(reportId, updates);
85+
setDetail(prev => ({...prev, status: nextStatus, developerReply: reply}));
86+
setConfirming(false);
87+
setSavedAt(new Date().toISOString());
88+
onChanged();
89+
} catch (err) {
90+
setError(err.message);
91+
} finally {
92+
setBusy(false);
93+
}
94+
}, [detail, nextStatus, reply, reportId, onChanged]);
95+
96+
if (error && !detail) {
5897
return (<p
5998
className="admin-error"
6099
data-testid="bug-admin-error"
@@ -78,12 +117,6 @@ const BugReportDetail = ({reportId, onBack}) => {
78117
{`報告者: ${detail.ownerEmail || '(メール非公開)'}${formatDate(detail.createdAt)}`}
79118
</p>
80119
<p data-testid="bug-admin-description">{detail.description}</p>
81-
{detail.developerReply ? (
82-
<p
83-
className="admin-meta"
84-
data-testid="bug-admin-reply"
85-
>{`開発者からの返信: ${detail.developerReply}`}</p>
86-
) : null}
87120
{detail.appContext ? (
88121
<details>
89122
<summary>{'アプリの状態 (appContext)'}</summary>
@@ -118,12 +151,82 @@ const BugReportDetail = ({reportId, onBack}) => {
118151
onError={hideBrokenImage}
119152
/>
120153
))}
154+
<div className="admin-respond">
155+
<h4>{'対応(報告者に表示されます)'}</h4>
156+
<label className="admin-meta">
157+
{'状態: '}
158+
<select
159+
data-testid="bug-admin-status-select"
160+
disabled={busy}
161+
value={nextStatus}
162+
onChange={handleStatusChange}
163+
>
164+
{Object.entries(STATUS_LABELS).map(([value, label]) => (
165+
<option
166+
key={value}
167+
value={value}
168+
>{label}</option>
169+
))}
170+
</select>
171+
</label>
172+
<textarea
173+
data-testid="bug-admin-reply-input"
174+
disabled={busy}
175+
maxLength={2000}
176+
placeholder="進捗や対応内容のコメント(報告者の「私の不具合報告」に表示されます)"
177+
rows={4}
178+
value={reply}
179+
onChange={handleReplyChange}
180+
/>
181+
{error ? <p
182+
className="admin-error"
183+
data-testid="bug-admin-save-error"
184+
>{error}</p> : null}
185+
<div className="admin-actions">
186+
{confirming ? (
187+
<span
188+
className="admin-confirm"
189+
data-testid="bug-admin-save-confirm"
190+
>
191+
{turnsTerminal ?
192+
'この状態にすると報告と添付は一定期間後に自動削除されます。保存しますか?' :
193+
'状態・コメントを保存しますか?報告者にも表示されます。'}
194+
<button
195+
data-testid="bug-admin-save-yes"
196+
disabled={busy}
197+
type="button"
198+
onClick={handleSave}
199+
>{'保存する'}</button>
200+
<button
201+
data-testid="bug-admin-save-no"
202+
disabled={busy}
203+
type="button"
204+
onClick={handleDisarm}
205+
>{'やめる'}</button>
206+
</span>
207+
) : (
208+
<button
209+
data-testid="bug-admin-save"
210+
disabled={!dirty || busy}
211+
type="button"
212+
onClick={handleArm}
213+
>{'保存'}</button>
214+
)}
215+
{savedAt && !dirty ? (
216+
<span
217+
className="admin-meta"
218+
data-testid="bug-admin-saved"
219+
>{'保存しました。'}</span>
220+
) : null}
221+
</div>
222+
</div>
121223
</div>
122224
);
123225
};
124226

125227
BugReportDetail.propTypes = {
126228
onBack: PropTypes.func.isRequired,
229+
onChanged: PropTypes.func.isRequired,
127230
reportId: PropTypes.string.isRequired
128231
};
129232

@@ -133,14 +236,16 @@ const BugReportsView = () => {
133236
const [selectedId, setSelectedId] = useState(null);
134237
const [error, setError] = useState('');
135238

136-
useEffect(() => {
239+
const reload = useCallback(() => {
137240
setError('');
138241
setReports(null);
139242
fetchBugReports(status)
140243
.then(data => setReports(data.reports || []))
141244
.catch(err => setError(err.message));
142245
}, [status]);
143246

247+
useEffect(reload, [reload]);
248+
144249
const handleStatusChange = useCallback(e => setStatus(e.target.value), []);
145250
const handleOpen = useCallback(e => setSelectedId(e.currentTarget.dataset.reportId), []);
146251
const handleBack = useCallback(() => setSelectedId(null), []);
@@ -150,14 +255,15 @@ const BugReportsView = () => {
150255
<BugReportDetail
151256
reportId={selectedId}
152257
onBack={handleBack}
258+
onChanged={reload}
153259
/>
154260
);
155261
}
156262

157263
return (
158264
<div data-testid="bug-admin-view">
159265
<p className="admin-meta">
160-
{'閲覧専用です。状態の変更・返信は従来の運用手段で行ってください。'}
266+
{'状態の変更とコメント(開発者からの返信)は、報告者の「私の不具合報告」にも反映されます。'}
161267
</p>
162268
<div className="admin-search">
163269
<select
Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
11
/**
2-
* Bug-report API client (EPIC #1073 S5, decision D)READ-ONLY.
2+
* Bug-report API client (EPIC #1073 S5, decision D — extended 2026-07-19).
33
*
4-
* The bug-report feature itself is unchanged; the admin console only adds a
5-
* viewing surface. This client therefore exposes GET endpoints exclusively —
6-
* status changes and developer replies stay on the existing workflow. The
7-
* admin's Google id_token is accepted by the bug-report Lambda as an
8-
* additional audience (decision F), and the operator must be registered in
9-
* the BugReportAdmins email registry.
4+
* The bug-report backend is unchanged: the admin console talks to the
5+
* EXISTING bug-report admin API. Originally view-only, the console now also
6+
* uses the existing PATCH endpoint for status changes and developer replies
7+
* (owner-requested spec addition). The admin's Google id_token is accepted
8+
* by the bug-report Lambda as an additional audience (decision F), and the
9+
* operator must be registered in the BugReportAdmins email registry.
1010
*/
1111
import {getIdToken} from './admin-api.js';
1212

1313
const BUG_REPORT_API_ENDPOINT = process.env.BUG_REPORT_API_ENDPOINT || '';
1414

1515
/**
16-
* Perform an authenticated GET against the bug-report API.
16+
* Perform an authenticated request against the bug-report API.
17+
* @param {string} method - HTTP method
1718
* @param {string} path - API path (starting with /admin/)
19+
* @param {object} [body] - JSON body for mutations
1820
* @returns {Promise<object>} parsed JSON
1921
*/
20-
const get = async path => {
22+
const request = async (method, path, body) => {
2123
const response = await fetch(`${BUG_REPORT_API_ENDPOINT}${path}`, {
22-
method: 'GET',
23-
headers: {Authorization: `Bearer ${getIdToken()}`}
24+
method,
25+
headers: {
26+
'Content-Type': 'application/json',
27+
'Authorization': `Bearer ${getIdToken()}`
28+
},
29+
...(body ? {body: JSON.stringify(body)} : {})
2430
});
2531
const data = await response.json().catch(() => ({}));
2632
if (!response.ok) {
@@ -41,13 +47,25 @@ const get = async path => {
4147
* @returns {Promise<object>} {reports}
4248
*/
4349
const fetchBugReports = status =>
44-
get(`/admin/bug-reports${status ? `?status=${encodeURIComponent(status)}` : ''}`);
50+
request('GET', `/admin/bug-reports${status ? `?status=${encodeURIComponent(status)}` : ''}`);
4551

4652
/**
4753
* One report with presigned download URLs (project / thumbnail / screenshots).
4854
* @param {string} reportId - report id
4955
* @returns {Promise<object>} report detail
5056
*/
51-
const fetchBugReport = reportId => get(`/admin/bug-reports/${reportId}`);
57+
const fetchBugReport = reportId => request('GET', `/admin/bug-reports/${reportId}`);
5258

53-
export {BUG_REPORT_API_ENDPOINT, fetchBugReports, fetchBugReport};
59+
/**
60+
* Update the status and/or developer reply (existing bug-report admin API).
61+
* Server side effects: the report is un-hidden for the reporter; a terminal
62+
* status (resolved / wont_fix) starts the auto-delete TTL, reopening clears
63+
* it.
64+
* @param {string} reportId - report id
65+
* @param {object} updates - {status?, developerReply?} (send only changes)
66+
* @returns {Promise<object>} updated report
67+
*/
68+
const updateBugReport = (reportId, updates) =>
69+
request('PATCH', `/admin/bug-reports/${reportId}`, updates);
70+
71+
export {BUG_REPORT_API_ENDPOINT, fetchBugReports, fetchBugReport, updateBugReport};

0 commit comments

Comments
 (0)