feat(workflows): admin CRUD + run-history + approvals-inbox API
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT writes a fresh node/edge set under version+1 and bumps workflows.version so in-flight runs keep their pinned version). Run-history (/:id/runs, /runs/:runId/steps) and the pending-approval inbox (GET /approvals, POST /approvals/:id/:action → actById) round it out. Gated by the workflows flag + RBAC (view for reads, manage for writes); built-in flows refuse delete; graph validated (exactly one trigger, unique keys, edges reference known nodes). Route tests cover CRUD, validation, version bump, toggle, inbox, and the 403 permission gate.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
let noPermToken;
|
||||
|
||||
const sampleGraph = {
|
||||
name: 'Test flow',
|
||||
trigger_type: 'invoice.sent',
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 'n1', type: 'trigger' },
|
||||
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [{ from_node: 'n1', to_node: 'n2' }],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'norole', email: 'nr@example.com', password_hash: 'x',
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
|
||||
|
||||
await db('feature_flags').insert({ key: 'workflows', value: true });
|
||||
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (t) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
describe('admin workflows API', () => {
|
||||
let createdId;
|
||||
|
||||
test('create → 201 with id', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBeGreaterThan(0);
|
||||
createdId = res.body.id;
|
||||
});
|
||||
|
||||
test('rejects a graph without exactly one trigger', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token))
|
||||
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('get one returns the graph', async () => {
|
||||
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodes).toHaveLength(2);
|
||||
expect(res.body.edges).toHaveLength(1);
|
||||
expect(res.body.version).toBe(1);
|
||||
});
|
||||
|
||||
test('list includes it', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some((w) => w.id === createdId)).toBe(true);
|
||||
});
|
||||
|
||||
test('update bumps the version', async () => {
|
||||
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
|
||||
.send({ ...sampleGraph, name: 'Renamed' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.version).toBe(2);
|
||||
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(get.body.name).toBe('Renamed');
|
||||
expect(get.body.version).toBe(2);
|
||||
});
|
||||
|
||||
test('enable toggle', async () => {
|
||||
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('approvals inbox returns an array', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
test('a role without workflows.manage is forbidden from writing', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -707,6 +707,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
|
||||
app.use('/api/admin/projects', require('./src/routes/adminProjects'));
|
||||
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
|
||||
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
|
||||
app.use('/api/admin/workflows', require('./src/routes/adminWorkflows'));
|
||||
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
|
||||
app.use('/api/admin/expenses', require('./src/routes/adminExpenses'));
|
||||
app.use('/api/admin/ledger', require('./src/routes/adminLedger'));
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Admin workflow management API.
|
||||
*
|
||||
* GET /api/admin/workflows list
|
||||
* GET /api/admin/workflows/approvals pending-approval inbox
|
||||
* POST /api/admin/workflows/approvals/:id/:act confirm|deny (webview)
|
||||
* GET /api/admin/workflows/runs/:runId/steps run step audit
|
||||
* GET /api/admin/workflows/:id/runs run history
|
||||
* GET /api/admin/workflows/:id one workflow + its graph
|
||||
* POST /api/admin/workflows create
|
||||
* PUT /api/admin/workflows/:id update (bumps version)
|
||||
* PATCH /api/admin/workflows/:id/enabled enable/disable
|
||||
* DELETE /api/admin/workflows/:id delete (built-ins refused)
|
||||
*
|
||||
* Versioning: editing writes a fresh node/edge set under version+1 and bumps
|
||||
* workflows.version; in-flight runs keep executing the version they pinned.
|
||||
* All endpoints gated by the `workflows` feature flag + RBAC (view/manage).
|
||||
*/
|
||||
const express = require('express');
|
||||
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const workflows = require('../services/workflows');
|
||||
|
||||
router.use(adminAuth, requireFeatureFlag('workflows'));
|
||||
|
||||
function parseJson(v, fallback) {
|
||||
if (v == null) return fallback;
|
||||
if (typeof v === 'object') return v;
|
||||
try { return JSON.parse(v); } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
function validateGraph(body) {
|
||||
const nodes = Array.isArray(body.nodes) ? body.nodes : [];
|
||||
const edges = Array.isArray(body.edges) ? body.edges : [];
|
||||
const triggers = nodes.filter((n) => n.type === 'trigger');
|
||||
if (triggers.length !== 1) return 'A workflow must have exactly one trigger node';
|
||||
if (nodes.some((n) => !n.node_key || !n.type)) return 'Every node needs a node_key and type';
|
||||
const keys = new Set(nodes.map((n) => n.node_key));
|
||||
if (keys.size !== nodes.length) return 'Duplicate node_key in graph';
|
||||
for (const e of edges) {
|
||||
if (!keys.has(e.from_node) || !keys.has(e.to_node)) return 'Edge references an unknown node';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeGraph(trx, workflowId, version, nodes = [], edges = []) {
|
||||
for (const n of nodes) {
|
||||
await trx('workflow_nodes').insert({
|
||||
workflow_id: workflowId, version, node_key: n.node_key, type: n.type,
|
||||
config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0,
|
||||
});
|
||||
}
|
||||
for (const e of edges) {
|
||||
await trx('workflow_edges').insert({
|
||||
workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null,
|
||||
to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Approvals inbox (registered before /:id so 'approvals' isn't an id) ---
|
||||
router.get('/approvals', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await workflows.listPending();
|
||||
res.json(items.map((a) => ({ ...a, payload: parseJson(a.payload, {}) })));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/approvals/:id/:action', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const { action } = req.params;
|
||||
if (!['confirm', 'deny'].includes(action)) return res.status(400).json({ error: 'Invalid action' });
|
||||
const result = await workflows.actById(Number(req.params.id), action, req.admin?.id);
|
||||
if (!result.ok && result.reason === 'not_found') return res.status(404).json({ error: 'Approval not found' });
|
||||
if (!result.ok && result.reason === 'expired') return res.status(410).json({ error: 'Approval expired' });
|
||||
res.json(result);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- Run history ---
|
||||
router.get('/runs/:runId/steps', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
const steps = await db('workflow_run_steps').where({ run_id: Number(req.params.runId) }).orderBy('id', 'asc');
|
||||
res.json(steps.map((s) => ({ ...s, result: parseJson(s.result, null) })));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
const runs = await db('workflow_runs').where({ workflow_id: Number(req.params.id) }).orderBy('id', 'desc').limit(200);
|
||||
res.json(runs.map((r) => ({ ...r, context: parseJson(r.context, {}) })));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- List / get ---
|
||||
router.get('/', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
const rows = await db('workflows').orderBy('id', 'desc');
|
||||
res.json(rows.map((w) => ({ ...w, trigger_config: parseJson(w.trigger_config, null) })));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.get('/:id', requirePermission('workflows.view'), async (req, res, next) => {
|
||||
try {
|
||||
const wf = await db('workflows').where({ id: Number(req.params.id) }).first();
|
||||
if (!wf) return res.status(404).json({ error: 'Workflow not found' });
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
const edges = await db('workflow_edges').where({ workflow_id: wf.id, version: wf.version });
|
||||
res.json({
|
||||
...wf,
|
||||
trigger_config: parseJson(wf.trigger_config, null),
|
||||
nodes: nodes.map((n) => ({ ...n, config: parseJson(n.config, {}) })),
|
||||
edges,
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- Create / update / toggle / delete ---
|
||||
router.post('/', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
if (!b.name || !b.trigger_type) return res.status(400).json({ error: 'name and trigger_type are required' });
|
||||
const err = validateGraph(b);
|
||||
if (err) return res.status(400).json({ error: err });
|
||||
const id = await db.transaction(async (trx) => {
|
||||
const ins = await trx('workflows').insert({
|
||||
name: b.name, description: b.description || null, enabled: !!b.enabled, version: 1,
|
||||
trigger_type: b.trigger_type, trigger_config: b.trigger_config ? JSON.stringify(b.trigger_config) : null,
|
||||
created_by: req.admin?.id || null,
|
||||
});
|
||||
const newId = ins[0];
|
||||
await writeGraph(trx, newId, 1, b.nodes, b.edges);
|
||||
return newId;
|
||||
});
|
||||
res.status(201).json({ id });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.put('/:id', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const wf = await db('workflows').where({ id }).first();
|
||||
if (!wf) return res.status(404).json({ error: 'Workflow not found' });
|
||||
const b = req.body || {};
|
||||
const err = validateGraph(b);
|
||||
if (err) return res.status(400).json({ error: err });
|
||||
const newVersion = wf.version + 1;
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('workflows').where({ id }).update({
|
||||
name: b.name ?? wf.name,
|
||||
description: b.description ?? wf.description,
|
||||
enabled: b.enabled != null ? !!b.enabled : wf.enabled,
|
||||
trigger_type: b.trigger_type ?? wf.trigger_type,
|
||||
trigger_config: b.trigger_config !== undefined
|
||||
? (b.trigger_config ? JSON.stringify(b.trigger_config) : null)
|
||||
: wf.trigger_config,
|
||||
version: newVersion,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
await writeGraph(trx, id, newVersion, b.nodes, b.edges);
|
||||
});
|
||||
res.json({ id, version: newVersion });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const enabled = !!(req.body && req.body.enabled);
|
||||
const updated = await db('workflows').where({ id }).update({ enabled, updated_at: db.fn.now() });
|
||||
if (!updated) return res.status(404).json({ error: 'Workflow not found' });
|
||||
res.json({ id, enabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id', requirePermission('workflows.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const wf = await db('workflows').where({ id }).first();
|
||||
if (!wf) return res.status(404).json({ error: 'Workflow not found' });
|
||||
if (wf.is_builtin) return res.status(409).json({ error: 'Built-in workflows cannot be deleted' });
|
||||
await db.transaction(async (trx) => {
|
||||
const runIds = (await trx('workflow_runs').where({ workflow_id: id }).select('id')).map((r) => r.id);
|
||||
if (runIds.length) {
|
||||
await trx('workflow_run_steps').whereIn('run_id', runIds).del();
|
||||
await trx('workflow_approvals').whereIn('run_id', runIds).del();
|
||||
}
|
||||
await trx('workflow_runs').where({ workflow_id: id }).del();
|
||||
await trx('workflow_edges').where({ workflow_id: id }).del();
|
||||
await trx('workflow_nodes').where({ workflow_id: id }).del();
|
||||
await trx('workflows').where({ id }).del();
|
||||
});
|
||||
res.json({ deleted: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user