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
33 changes: 21 additions & 12 deletions api/admin/claim-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion public/admin/claims.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ <h1>CommandLayer Claims Admin</h1>
claimActionError = {
status: data.status || 'REQUEST_FAILED',
error: data.error || 'Request failed.',
debug: data.debug || null,
fromStatus: data.fromStatus,
action: data.action || action
};
Expand Down Expand Up @@ -85,7 +86,8 @@ <h3>Agents</h3><pre>${JSON.stringify(data.agents || [], null, 2)}</pre>`;
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 = '';
Expand Down
62 changes: 53 additions & 9 deletions tests/api-admin-claims.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
Loading