Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -0,0 +1,715 @@
# DESIGN-0.37.15 — Workflow Ownership & Lifecycle
Fixes the two structural problems in the workflow system: (1) workflows
are admin-only with no way to clean up stale instances, and (2) the FE
surfaces don't expose the team-admin capabilities the BE already has.
90%+ of workflows are team-admin generated. Assignments happen to team
members on their stage turn. This design makes that the first-class path.
Depends on: v0.37.14 (FE Rewrite), v0.35.0 (Workflow Product).
---
## Use Cases
### UC1 — Pure Data Gather (no LLM, no chat)
**Example:** Employee onboarding form. HR team admin creates a 3-stage
workflow: (1) employee fills a form, (2) HR reviews and approves,
(3) IT provisions accounts.
- Stage 0: `form_only`, `entry_mode: public_link` — visitor sees a form
- Stage 1: `review`, `assignment_team_id: hr-team` — HR member claims, reviews data, advances
- Stage 2: `form_only`, `assignment_team_id: it-team` — IT fills provisioning fields, completes
**No LLM involved at any stage.** No chat pane. The visitor never sees
stages 1-2. They get a terminal "submitted" screen after stage 0.
### UC2 — Data Gather + Live Chat with Team Member (no LLM)
**Example:** Customer support intake. Customer fills a form describing
their issue, then gets a live chat channel with a support agent.
- Stage 0: `form_only`, `entry_mode: public_link` — customer fills issue form
- Stage 1: `form_chat`, `assignment_team_id: support-team` — agent claims, chats with customer
- Stage 2: `review`, `assignment_team_id: support-leads` — lead reviews transcript, closes
**No LLM.** The chat is human-to-human. The customer sees stages 0-1
(their form and the chat). Stage 2 is internal.
### UC3 — LLM-Driven Intake Triggering Team Member
**Example:** IT help desk. An AI persona does first-level triage via
chat, gathers structured data, then routes to the right team.
- Stage 0: `chat_only`, `persona_id: helpdesk-bot`, `entry_mode: public_link` — LLM chats with user, extracts issue type
- Stage 1: `review`, `assignment_team_id` resolved by routing rules — team member reviews AI summary + transcript
- Stage 2: `form_chat`, same team — agent works with user if needed, fills resolution form
**LLM is stage 0 only.** Stages 1-2 are human. Conditional routing
(`transition_rules.conditions`) determines which team gets stage 1 based
on data the LLM extracted.
### Cross-Cutting: Stage Visibility
Stages have an audience:
| Audience | Who sees it | Examples |
|----------|-------------|---------|
| `visitor` | The person who entered the workflow | Stage 0 form, stage 1 chat with agent |
| `team` | Members of the assigned team only | Internal review, triage, approval |
| `system` | No human UI — automated transition | Webhook fire, data enrichment hook |
**Rule:** Once a stage's audience is `team`, the visitor's view is
frozen. They see a "your request is being processed" terminal. They
never see the internal stages, the assignment queue, or downstream
team handoffs.
This is NOT a new DB column. It's implied by the stage configuration:
- `form_only` or `chat_only` with no `assignment_team_id` → visitor-facing
- Any stage with `assignment_team_id` → team-facing
- `auto_transition: true` with no persona and no form → system
The FE reads the stage config and renders accordingly.
### Cross-Cutting: Multi-Team Handoffs
Stage 1 is Team A (triage). Stage 2 is Team B (specialist). Stage 3
is Team A again (close-out). Each team sees ONLY their assignments.
`ListAssignmentsForTeam` already filters by `team_id`. The FE must
scope the queue view to the selected team context.
### Cross-Cutting: Instance Lifecycle
Current status enum: `active | completed`.
Add: `cancelled`.
Who can cancel:
- The instance owner (the user who started it)
- A team admin of the owning team
- A global admin
Cancelling an instance sets `workflow_status = 'cancelled'` and
cancels all open assignments (`status IN ('unassigned','claimed')`).
### Cross-Cutting: Assignment Lifecycle
Current status enum: `unassigned | claimed | completed`.
Add: `cancelled`.
New operations:
- **Unclaim** — returns `claimed``unassigned` (claimer or team admin)
- **Reassign** — changes `assigned_to` on a claimed assignment (team admin)
- **Cancel** — sets `cancelled` (team admin, fires when instance is cancelled)
---
## JSX Exemplars
These are specification-grade components. Implementation translates to
the project's `htm` tagged template syntax. Props match the API response
shapes. State management uses the SDK (`sw.api.*`).
### E1 — Team Workflow Queue (the "inbox")
This is the primary surface a team member sees. It replaces the current
bare list of workflows with an actionable assignment queue.
```jsx
/**
* TeamWorkflowQueue — assignment inbox for a team member
*
* Shows: my claimed assignments, then unassigned assignments I can claim.
* Actions: claim, unclaim, open (navigate to workflow channel), complete.
*
* Mount: team-admin surface, "Assignments" tab
* Data: GET /api/v1/teams/:teamId/assignments?status=unassigned
* GET /api/v1/teams/:teamId/assignments?status=claimed
*/
function TeamWorkflowQueue({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
// WS live update: listen for workflow.assigned / workflow.claimed
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
return () => { off1(); off2(); };
}, [teamId]);
async function claim(id) {
await sw.api.workflowAssignments.claim(id);
load();
}
async function unclaim(id) {
await sw.api.workflowAssignments.unclaim(id);
load();
}
return (
<div className="wf-queue">
{/* ── My Claimed ── */}
<h4>My Active ({claimed.length})</h4>
{claimed.map(a => (
<div key={a.id} className="wf-queue-item wf-queue-item--claimed">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
</div>
<div className="wf-queue-item__actions">
<button className="btn-small" onClick={() => unclaim(a.id)}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
</div>
</div>
))}
{/* ── Unassigned ── */}
<h4>Available ({unassigned.length})</h4>
{unassigned.map(a => (
<div key={a.id} className="wf-queue-item">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
<button className="btn-small btn-primary" onClick={() => claim(a.id)}>
Claim
</button>
</div>
</div>
))}
{claimed.length === 0 && unassigned.length === 0 && (
<div className="empty-hint">No assignments queue is clear</div>
)}
</div>
);
}
```
### E2 — Stage Editor (team-admin inline)
The current team-admin workflows surface shows stages read-only. This
replaces it with a full inline editor.
```jsx
/**
* StageEditor — inline stage list with add/edit/delete/reorder
*
* Mount: team-admin workflows surface, inside the workflow edit view.
* Data: GET /api/v1/teams/:teamId/workflows/:wfId/stages
* POST /api/v1/teams/:teamId/workflows/:wfId/stages
* PUT /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* DELETE /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder
*/
function StageEditor({ teamId, workflowId }) {
const [stages, setStages] = useState([]);
const [editing, setEditing] = useState(null); // stage id or 'new'
const [teams, setTeams] = useState([]);
const [personas, setPersonas] = useState([]);
async function load() {
const [s, t, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflowId),
sw.api.teams.members(teamId), // for assignment_team picker
sw.api.teams.personas(teamId), // for persona picker
]);
setStages(s || []);
setTeams(t || []);
setPersonas(p || []);
}
useEffect(() => { load(); }, [workflowId]);
async function addStage(data) {
await sw.api.teams.createWorkflowStage(teamId, workflowId, data);
setEditing(null);
load();
}
async function updateStage(stageId, data) {
await sw.api.teams.updateWorkflowStage(teamId, workflowId, stageId, data);
setEditing(null);
load();
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
await sw.api.teams.deleteWorkflowStage(teamId, workflowId, stageId);
load();
}
return (
<div className="stage-editor">
<div className="stage-editor__header">
<h5>Stages ({stages.length})</h5>
<button className="btn-small" onClick={() => setEditing('new')}>+ Add Stage</button>
</div>
{/* ── Stage list (drag handle placeholder for reorder) ── */}
{stages.map((s, i) => (
<div key={s.id} className="stage-editor__row">
<span className="stage-editor__ordinal">#{i + 1}</span>
<div className="stage-editor__info">
<strong>{s.name}</strong>
<span className="badge">{s.stage_mode}</span>
{s.persona_id && <span className="badge badge-active">persona</span>}
{s.assignment_team_id && <span className="badge">team assign</span>}
</div>
<div className="stage-editor__actions">
<button className="btn-small" onClick={() => setEditing(s.id)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => deleteStage(s.id)}>×</button>
</div>
</div>
))}
{/* ── Inline edit/create form ── */}
{editing && (
<StageForm
stage={editing === 'new' ? null : stages.find(s => s.id === editing)}
personas={personas}
teams={teams}
onSave={(data) => editing === 'new'
? addStage(data)
: updateStage(editing, data)
}
onCancel={() => setEditing(null)}
/>
)}
</div>
);
}
/**
* StageForm — create/edit a single stage
*
* Fields: name, stage_mode, persona_id, assignment_team_id,
* history_mode, auto_transition, sla_seconds.
* Form template editing is a separate concern (CS6+).
*/
function StageForm({ stage, personas, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
function submit() {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
}
return (
<div className="stage-form">
<div className="form-row">
<div className="form-group">
<label>Name</label>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="form-group">
<label>Mode</label>
<select value={mode} onChange={e => setMode(e.target.value)}>
<option value="chat_only">Chat Only</option>
<option value="form_only">Form Only</option>
<option value="form_chat">Form + Chat</option>
<option value="review">Review</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Persona (LLM)</label>
<select value={personaId} onChange={e => setPersonaId(e.target.value)}>
<option value=""> none </option>
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Assign to Team</label>
<select value={assignTeam} onChange={e => setAssignTeam(e.target.value)}>
<option value=""> none (visitor stage) </option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>History Mode</label>
<select value={historyMode} onChange={e => setHistoryMode(e.target.value)}>
<option value="full">Full</option>
<option value="summary">Summary</option>
<option value="fresh">Fresh</option>
</select>
</div>
<div className="form-group">
<label>SLA (seconds, optional)</label>
<input type="number" value={sla} onChange={e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
<label className="toggle-label">
<input type="checkbox" checked={autoTransition} onChange={e => setAutoTransition(e.target.checked)} />
Auto-advance when complete
</label>
<div className="form-row" style={{ marginTop: 12, gap: 8 }}>
<button className="btn-small btn-primary" onClick={submit}>
{stage ? 'Update' : 'Add Stage'}
</button>
<button className="btn-small" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
```
### E3 — Instance Monitor (team-admin)
Shows active instances for the team's workflows with cancel capability.
```jsx
/**
* TeamInstanceMonitor — active workflow instances for a team
*
* Mount: team-admin workflows surface, "Monitor" tab
* Data: GET /api/v1/teams/:teamId/workflows/monitor/instances
* Actions: cancel instance
*/
function TeamInstanceMonitor({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
async function cancelInstance(channelId) {
const ok = await sw.confirm(
'Cancel this workflow instance? All open assignments will be cancelled.', true
);
if (!ok) return;
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
}
return (
<div className="wf-monitor">
<h4>Active Instances ({instances.length})</h4>
{instances.map(inst => (
<div key={inst.channel_id} className="wf-monitor__row">
<div className="wf-monitor__info">
<strong>{inst.workflow_name}</strong>
<span className="badge">{inst.stage_name || `Stage ${inst.current_stage}`}</span>
{inst.sla_breached && <span className="badge badge-danger">SLA breached</span>}
<span className="text-muted">Age: {formatDuration(inst.age_seconds)}</span>
</div>
<div className="wf-monitor__actions">
<button className="btn-small"
onClick={() => location.href = `${BASE}/w/${inst.channel_id}`}>
Open
</button>
<button className="btn-small btn-danger" onClick={() => cancelInstance(inst.channel_id)}>
Cancel
</button>
</div>
</div>
))}
{instances.length === 0 && <div className="empty-hint">No active instances</div>}
</div>
);
}
```
### E4 — My Assignments (user settings)
Every authenticated user sees their personal assignment queue across
all teams they belong to. This is the "what's on my plate" view.
```jsx
/**
* MyAssignments — user's personal assignment queue
*
* Mount: settings surface, "Assignments" section
* Data: GET /api/v1/workflow-assignments/mine
* Actions: claim, unclaim, open channel, complete
*/
function MyAssignments() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
setLoading(false);
}
useEffect(() => { load(); }, []);
// Group: my claimed first, then available
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === sw.auth.user?.id);
const available = assignments.filter(a => a.status === 'unassigned');
return (
<div>
{mine.length > 0 && (
<>
<h4>Claimed ({mine.length})</h4>
{mine.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{available.length > 0 && (
<>
<h4>Available ({available.length})</h4>
{available.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{mine.length === 0 && available.length === 0 && (
<div className="empty-hint">No assignments</div>
)}
</div>
);
}
/**
* AssignmentRow — reusable row for any assignment context
*
* Shared between TeamWorkflowQueue (E1) and MyAssignments (E4).
* The showTeam prop controls whether the team badge is visible
* (needed in MyAssignments where assignments span multiple teams).
*/
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const isMine = a.assigned_to === sw.auth.user?.id;
async function claim() {
await sw.api.workflowAssignments.claim(a.id);
onAction();
}
async function unclaim() {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
}
async function complete() {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
}
return (
<div className={`wf-queue-item ${isMine ? 'wf-queue-item--claimed' : ''}`}>
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
{showTeam && a.team_name && <span className="badge">{a.team_name}</span>}
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
{a.status === 'unassigned' && (
<button className="btn-small btn-primary" onClick={claim}>Claim</button>
)}
{isMine && (
<>
<button className="btn-small" onClick={unclaim}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
<button className="btn-small btn-success" onClick={complete}>Complete</button>
</>
)}
</div>
</div>
);
}
```
### E5 — Visitor Terminal Screen
When a visitor completes their last visible stage, they see this
instead of the internal team stages.
```jsx
/**
* VisitorTerminal — "thank you" screen after visitor stages complete
*
* The workflow surface (Go template + JS) detects when the current
* stage is team-facing and the visitor is not a team member.
* Instead of rendering the stage, it renders this terminal.
*
* Mount: workflow surface (replaces stage content)
* Data: channel workflow_status + stage config
*/
function VisitorTerminal({ workflowName, branding, status }) {
// status: 'active' (still being processed) | 'completed' | 'cancelled'
const messages = {
active: { title: 'Request Submitted', body: 'Your request is being reviewed. You\'ll be contacted if we need more information.' },
completed: { title: 'Complete', body: 'Your request has been processed. Thank you.' },
cancelled: { title: 'Cancelled', body: 'This request has been cancelled.' },
};
const msg = messages[status] || messages.active;
return (
<div className="visitor-terminal">
{branding?.logo_url && (
<img src={branding.logo_url} className="visitor-terminal__logo" alt="" />
)}
<h2>{msg.title}</h2>
<p>{msg.body}</p>
{branding?.tagline && (
<p className="visitor-terminal__tagline">{branding.tagline}</p>
)}
</div>
);
}
```
---
## BE Additions
### New Store Methods
```go
// ── ChannelStore additions ──
// CancelWorkflow sets workflow_status = 'cancelled' on a workflow channel.
CancelWorkflow(ctx context.Context, channelID string) error
// ── WorkflowStore additions ──
// CancelAssignmentsForChannel sets status = 'cancelled' on all
// unassigned/claimed assignments for a channel.
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
// UnclaimAssignment returns a claimed assignment to unassigned.
// Returns rows affected (0 = not claimed or not found).
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
// ReassignAssignment changes assigned_to on a claimed assignment.
// Returns rows affected.
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
// CancelAssignment sets a single assignment to cancelled.
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
```
### New Handler Endpoints
```
POST /api/v1/channels/:id/workflow/cancel — owner or team admin
POST /api/v1/workflow-assignments/:id/unclaim — claimer or team admin
POST /api/v1/workflow-assignments/:id/reassign — team admin
POST /api/v1/workflow-assignments/:id/cancel — team admin
# Team-scoped variants (for team-admin surface):
POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
```
### New FE API Domains
```js
// teams domain additions:
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
// stage CRUD (routes exist, FE bindings missing):
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
// workflowAssignments domain (new top-level):
workflowAssignments: {
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
},
```
### Schema Notes
No new migration files needed IF `workflow_status` and assignment
`status` are TEXT columns (they are). The new `cancelled` value is
just a string. Verify with:
```sql
-- Should return TEXT, not an enum:
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'channels' AND column_name = 'workflow_status';
```
---
## Changeset Plan
| CS | Scope | Files | Notes |
|----|-------|-------|-------|
| CS1 | BE: instance cancel | store iface + pg/sqlite impl + handler + routes | CancelWorkflow, CancelAssignmentsForChannel |
| CS2 | BE: assignment lifecycle | store iface + pg/sqlite impl + handler + routes | Unclaim, Reassign, CancelAssignment |
| CS3 | FE: api-domains wiring | `api-domains.js` | Wire all missing team stage CRUD + assignment operations |
| CS4 | FE: team-admin workflows rewrite | `team-admin/workflows.js` (split to sub-files if needed) | Stage editor (E2), instance monitor (E3), queue (E1) |
| CS5 | FE: settings assignments | `settings/workflows.js` → rename to `settings/assignments.js` | MyAssignments (E4), replaces read-only workflow list |
CS1-CS2 are BE-only, testable via integration tests. CS3 is a single
file, no visual changes. CS4-CS5 are FE-only, depend on CS3.
---
## What We're NOT Doing in .15
- Form template visual editor (stage form_template is still raw JSON)
- Workflow template sharing between teams
- Bulk operations (batch cancel, batch reassign)
- SLA alerting/notification automation (monitor is read-only)
- Visitor terminal customization beyond branding (E5 is static)
- Drag-and-drop stage reorder (button-based reorder only)
- Workflow analytics/reporting beyond the existing funnel endpoint