From 2651a5541a11b524120199427181233f9ba7c028 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 16:36:54 -0400 Subject: [PATCH] Fix admin mark_failed and add_note claim actions --- api/admin/claim-action.js | 33 +++++++++++------- public/admin/claims.html | 4 ++- tests/api-admin-claims.test.js | 62 +++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/api/admin/claim-action.js b/api/admin/claim-action.js index aaf34b4..dc94edc 100644 --- a/api/admin/claim-action.js +++ b/api/admin/claim-action.js @@ -43,12 +43,17 @@ module.exports = async function handler(req, res) { if ((action === 'reject' || action === 'mark_failed') && !reason) { return res.status(400).json({ ok: false, status: 'REASON_REQUIRED' }); } + if (action === 'add_note' && !notes) { + return res.status(400).json({ ok: false, status: 'NOTES_REQUIRED' }); + } + + let fromStatus = null; try { const claimRows = db.normalizeRows(await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId])); if (!claimRows.length) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' }); const claim = claimRows[0]; - const fromStatus = claim.status; + fromStatus = claim.status; if (action === 'approve') { const allow = fromStatus === CLAIM_STATUSES.CREATED || (fromStatus === CLAIM_STATUSES.REJECTED && override); @@ -99,29 +104,33 @@ module.exports = async function handler(req, res) { where claim_id = $1`, [claimId, CLAIM_STATUSES.FAILED, reason] ); - await insertEventAndTransition({ claimId, fromStatus, toStatus: CLAIM_STATUSES.FAILED, action, actor, reason, notes, eventType: 'claim.failed' }); + await insertEventAndTransition({ claimId, fromStatus, toStatus: CLAIM_STATUSES.FAILED, action, actor, reason, notes, eventType: 'claim.failed', metadata: { previousStatus: fromStatus, actor } }); return res.status(200).json({ ok: true, status: 'CLAIM_ACTION_APPLIED', claimId, action, claimStatus: CLAIM_STATUSES.FAILED }); } - const mergedNotes = [claim.admin_notes, notes || reason].filter(Boolean).join('\n').trim(); - await db.query('update claim_requests set admin_notes = $2 where claim_id = $1', [claimId, mergedNotes]); + const mergedNotes = [claim.admin_notes, notes].filter(Boolean).join('\n').trim(); + await db.query('update claim_requests set admin_notes = $2 where claim_id = $1', [claimId, mergedNotes || notes]); await db.query( - `insert into claim_events (claim_id, event_type, actor, event_json) - values ($1, 'claim.note_added', $2, $3::jsonb)`, - [claimId, actor, JSON.stringify({ action, reason, notes })] + `insert into claim_events (claim_id, event_type, actor, message, event_json) + values ($1, 'claim.note_added', $2, $3, $4::jsonb)`, + [claimId, actor, notes, JSON.stringify({ action, notes, actor })] ); return res.status(200).json({ ok: true, status: 'CLAIM_ACTION_APPLIED', claimId, action, claimStatus: fromStatus }); } catch (error) { - console.error('ADMIN_CLAIM_ACTION_FAILED', { message: error.message, code: error.code }); - return res.status(500).json({ ok: false, status: 'ADMIN_CLAIM_ACTION_FAILED', error: 'Failed to apply claim action.' }); + const debug = { message: error.message, code: error.code }; + console.error('ADMIN_CLAIM_ACTION_FAILED', { ...debug, action, claimId, currentStatus: typeof fromStatus === 'string' ? fromStatus : null }); + const payload = { ok: false, status: 'ADMIN_CLAIM_ACTION_FAILED', error: 'Failed to apply claim action.' }; + if (process.env.NODE_ENV !== 'production') payload.debug = debug; + return res.status(500).json(payload); } }; async function insertEventAndTransition({ claimId, fromStatus, toStatus, action, actor, reason, notes, eventType, metadata }) { + const message = eventType === 'claim.failed' ? reason : notes || reason || null; await db.query( - `insert into claim_events (claim_id, event_type, actor, event_json) - values ($1, $2, $3, $4::jsonb)`, - [claimId, eventType, actor, JSON.stringify({ action, reason, notes, ...(metadata || {}) })] + `insert into claim_events (claim_id, event_type, actor, message, event_json) + values ($1, $2, $3, $4, $5::jsonb)`, + [claimId, eventType, actor, message, JSON.stringify({ action, reason, notes, ...(metadata || {}) })] ); await db.query( `insert into claim_status_transitions (claim_id, from_status, to_status, action, actor, reason, metadata_json) diff --git a/public/admin/claims.html b/public/admin/claims.html index 6cd1599..c951bdd 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -56,6 +56,7 @@

CommandLayer Claims Admin

claimActionError = { status: data.status || 'REQUEST_FAILED', error: data.error || 'Request failed.', + debug: data.debug || null, fromStatus: data.fromStatus, action: data.action || action }; @@ -85,7 +86,8 @@

Agents

${JSON.stringify(data.agents || [], null, 2)}
`; const errorPanel = document.getElementById('actionErrorPanel'); if (claimActionError) { errorPanel.className = 'error-panel'; - errorPanel.textContent = `Action failed: ${claimActionError.status} — ${claimActionError.error}`; + const detailText = claimActionError.debug && claimActionError.debug.message ? `\nDetails: ${claimActionError.debug.message}` : ''; + errorPanel.textContent = `Action failed:\n${claimActionError.status} — ${claimActionError.error}${detailText}`; } else { errorPanel.textContent = ''; errorPanel.className = ''; diff --git a/tests/api-admin-claims.test.js b/tests/api-admin-claims.test.js index bd06cfb..6c0a77b 100644 --- a/tests/api-admin-claims.test.js +++ b/tests/api-admin-claims.test.js @@ -90,18 +90,62 @@ test('approving an already approved claim returns explicit transition error', as assert.equal(res.body.action, 'approve'); }); -test('mark_failed requires reason and add_note does not change status while inserting event', async () => { +test('mark_failed without reason returns REASON_REQUIRED', async () => { process.env.ADMIN_API_KEY = 'secret'; - let handler = load('../api/admin/claim-action', async () => []); - let res = makeRes(); + const handler = load('../api/admin/claim-action', async () => []); + const res = makeRes(); await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1', action: 'mark_failed', actor: 'admin' } }, res); - assert.equal(res.statusCode, 400); assert.equal(res.body.status, 'REASON_REQUIRED'); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'REASON_REQUIRED'); +}); +test('mark_failed from approved succeeds, inserts event and transition', async () => { + process.env.ADMIN_API_KEY = 'secret'; const calls = []; - handler = load('../api/admin/claim-action', async (text, params) => { calls.push(String(text)); if (String(text).includes('from claim_requests')) return [{ claim_id: 'clm_1', status: 'approved', admin_notes: 'n1' }]; return []; }); - res = makeRes(); + const handler = load('../api/admin/claim-action', async (text, params) => { + calls.push({ text: String(text), params }); + if (String(text).includes('from claim_requests')) return [{ claim_id: 'clm_1', status: 'approved', admin_notes: 'n1' }]; + return []; + }); + const res = makeRes(); + await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1', action: 'mark_failed', actor: 'admin', reason: 'ops failed' } }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.claimStatus, 'failed'); + assert.ok(calls.some((c) => c.text.includes('update claim_requests') && c.params[1] === 'failed' && c.params[2] === 'ops failed')); + assert.ok(calls.some((c) => c.text.includes('insert into claim_events') && c.params[1] === 'claim.failed' && c.params[3] === 'ops failed')); + assert.ok(calls.some((c) => c.text.includes('insert into claim_status_transitions') && c.params[1] === 'approved' && c.params[2] === 'failed')); +}); + +test('add_note from approved succeeds, inserts note event, does not insert transition', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const calls = []; + const handler = load('../api/admin/claim-action', async (text, params) => { + calls.push({ text: String(text), params }); + if (String(text).includes('from claim_requests')) return [{ claim_id: 'clm_1', status: 'approved', admin_notes: 'n1' }]; + return []; + }); + const res = makeRes(); await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1', action: 'add_note', actor: 'admin', notes: 'n2' } }, res); - assert.equal(res.statusCode, 200); assert.equal(res.body.claimStatus, 'approved'); - assert.ok(calls.some((q) => q.includes('insert into claim_events'))); - assert.equal(calls.some((q) => q.includes('insert into claim_status_transitions')), false); + assert.equal(res.statusCode, 200); + assert.equal(res.body.claimStatus, 'approved'); + assert.ok(calls.some((c) => c.text.includes('update claim_requests set admin_notes'))); + assert.ok(calls.some((c) => c.text.includes("insert into claim_events (claim_id, event_type, actor, message") && c.params[2] === 'n2')); + assert.equal(calls.some((c) => c.text.includes('insert into claim_status_transitions')), false); +}); + +test('add_note without notes returns NOTES_REQUIRED', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const handler = load('../api/admin/claim-action', async () => []); + const res = makeRes(); + await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1', action: 'add_note', actor: 'admin' } }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'NOTES_REQUIRED'); +}); + + + +test('frontend claim actions use expected payload keys', async () => { + const html = require('node:fs').readFileSync(require('node:path').join(__dirname, '../public/admin/claims.html'), 'utf8'); + assert.ok(html.includes("claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })")); + assert.ok(html.includes("claimAction('add_note', { notes: document.getElementById('notesInput').value")); });