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
6 changes: 3 additions & 3 deletions api/admin/claim-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ module.exports = async function handler(req, res) {
if (action === 'approve') {
const allow = fromStatus === CLAIM_STATUSES.CREATED || (fromStatus === CLAIM_STATUSES.REJECTED && override);
if (!allow) {
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot approve claim from ${fromStatus}` });
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot approve claim from ${fromStatus} status.`, fromStatus, action });
}
await db.query(
`update claim_requests
Expand All @@ -71,7 +71,7 @@ module.exports = async function handler(req, res) {

if (action === 'reject') {
if (fromStatus !== CLAIM_STATUSES.CREATED) {
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot reject claim from ${fromStatus}` });
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot reject claim from ${fromStatus} status.`, fromStatus, action });
}
await db.query(
`update claim_requests
Expand All @@ -89,7 +89,7 @@ module.exports = async function handler(req, res) {

if (action === 'mark_failed') {
if (fromStatus === CLAIM_STATUSES.LIVE) {
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot mark_failed claim from ${fromStatus}` });
return res.status(409).json({ ok: false, status: 'INVALID_STATUS_TRANSITION', error: `Cannot mark_failed claim from ${fromStatus} status.`, fromStatus, action });
}
await db.query(
`update claim_requests
Expand Down
28 changes: 25 additions & 3 deletions public/admin/claims.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
button { padding: 8px 12px; }
.muted { color: #6b7280; }
.status-badge { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #e5e7eb; font-weight: 700; }
.error-panel { margin: 10px 0; padding: 10px; border-radius: 8px; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
</style>
</head>
<body>
Expand All @@ -37,6 +38,7 @@ <h1>CommandLayer Claims Admin</h1>
const claimsBody = document.getElementById('claimsBody');
const detail = document.getElementById('detail');
let selectedClaimId = '';
let claimActionError = null;
apiKeyInput.value = sessionStorage.getItem('cl_admin_api_key') || localStorage.getItem('cl_admin_api_key') || '';
function authHeaders() { return { Authorization: `Bearer ${apiKeyInput.value.trim()}`, 'Content-Type': 'application/json' }; }
saveKeyBtn.addEventListener('click', () => { const v = apiKeyInput.value.trim(); sessionStorage.setItem('cl_admin_api_key', v); localStorage.setItem('cl_admin_api_key', v); statusEl.textContent = 'Saved.'; });
Expand All @@ -50,8 +52,19 @@ <h1>CommandLayer Claims Admin</h1>
async function claimAction(action, payload = {}) {
const res = await fetch('/api/admin/claim-action', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ claimId: selectedClaimId, action, actor: 'admin', ...payload }) });
const data = await res.json();
if (!res.ok || !data.ok) { alert(`${data.status}: ${data.error || 'request failed'}`); return; }
await loadDetail(selectedClaimId); await loadClaims();
if (!res.ok || !data.ok) {
claimActionError = {
status: data.status || 'REQUEST_FAILED',
error: data.error || 'Request failed.',
fromStatus: data.fromStatus,
action: data.action || action
};
await loadDetail(selectedClaimId);
return;
}
claimActionError = null;
await loadDetail(selectedClaimId);
await loadClaims();
}
async function loadDetail(claimId) {
selectedClaimId = claimId;
Expand All @@ -60,6 +73,7 @@ <h1>CommandLayer Claims Admin</h1>
if (!res.ok || !data.ok) { detail.textContent = `${res.status} ${data.status || 'error'}`; return; }
const status = data.claim.status;
detail.innerHTML = `<p><strong>claim ID:</strong> ${data.claim.claim_id}</p><p><strong>Status:</strong> <span class="status-badge">${status}</span></p>
<div id="actionErrorPanel"></div>
<div class="row" id="actionRow"></div><p id="nextStep" class="muted"></p>
<div><label>Reason</label><input id="reasonInput" placeholder="Reason for reject/failure" /></div>
<div><label>Notes</label><textarea id="notesInput" placeholder="Admin notes"></textarea></div>
Expand All @@ -68,10 +82,18 @@ <h3>Transitions</h3><pre>${JSON.stringify(data.transitions || [], null, 2)}</pre
<h3>Events</h3><pre>${JSON.stringify(data.events || [], null, 2)}</pre>
<h3>Agents</h3><pre>${JSON.stringify(data.agents || [], null, 2)}</pre>`;
const actionRow = document.getElementById('actionRow');
const errorPanel = document.getElementById('actionErrorPanel');
if (claimActionError) {
errorPanel.className = 'error-panel';
errorPanel.textContent = `Action failed: ${claimActionError.status} — ${claimActionError.error}`;
} else {
errorPanel.textContent = '';
errorPanel.className = '';
}
const mk = (label, cb) => { const b = document.createElement('button'); b.textContent = label; b.addEventListener('click', cb); actionRow.appendChild(b); };
if (status === 'created') { mk('Approve', () => claimAction('approve', { notes: document.getElementById('notesInput').value })); mk('Reject', () => claimAction('reject', { reason: document.getElementById('reasonInput').value })); mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); }
if (status === 'approved') { document.getElementById('nextStep').textContent = 'Next: Publish agent cards (placeholder)'; mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); }
if (status === 'rejected') { mk('Reopen / Approve (override)', () => claimAction('approve', { override: true, reason: document.getElementById('reasonInput').value, notes: document.getElementById('notesInput').value })); }
if (status === 'rejected') { mk('Reopen / Approve (override)', () => claimAction('approve', { override: true, reason: document.getElementById('reasonInput').value, notes: document.getElementById('notesInput').value })); mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); }
if (status === 'failed') { document.getElementById('nextStep').textContent = 'Failed status: auto-retry is not enabled yet.'; }
document.getElementById('saveNoteBtn').addEventListener('click', () => claimAction('add_note', { notes: document.getElementById('notesInput').value, reason: document.getElementById('reasonInput').value }));
document.getElementById('refreshBtn').addEventListener('click', () => loadDetail(claimId));
Expand Down
10 changes: 7 additions & 3 deletions tests/api-admin-claims.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,16 @@ test('reject created claim with reason succeeds; reject without reason fails', a
assert.equal(res.statusCode, 400); assert.equal(res.body.status, 'REASON_REQUIRED');
});

test('invalid transition fails', async () => {
test('approving an already approved claim returns explicit transition error', async () => {
process.env.ADMIN_API_KEY = 'secret';
const handler = load('../api/admin/claim-action', async (text) => String(text).includes('from claim_requests') ? [{ claim_id: 'clm_1', status: 'cards_published' }] : []);
const handler = load('../api/admin/claim-action', async (text) => String(text).includes('from claim_requests') ? [{ claim_id: 'clm_1', status: 'approved' }] : []);
const res = makeRes();
await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1', action: 'approve', actor: 'admin' } }, res);
assert.equal(res.statusCode, 409); assert.equal(res.body.status, 'INVALID_STATUS_TRANSITION');
assert.equal(res.statusCode, 409);
assert.equal(res.body.status, 'INVALID_STATUS_TRANSITION');
assert.equal(res.body.error, 'Cannot approve claim from approved status.');
assert.equal(res.body.fromStatus, 'approved');
assert.equal(res.body.action, 'approve');
});

test('mark_failed requires reason and add_note does not change status while inserting event', async () => {
Expand Down
Loading