Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
715
docs/Workflow/DESIGN-0.37.15.md
Normal file
715
docs/Workflow/DESIGN-0.37.15.md
Normal 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
|
||||
584
docs/Workflow/ROADMAP-0.38.md
Normal file
584
docs/Workflow/ROADMAP-0.38.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# ROADMAP-0.38 — Workflow Product Maturity
|
||||
|
||||
Everything deferred from v0.37.15 plus what the MVP gate actually
|
||||
requires: "Team admins build workflows **visually**."
|
||||
|
||||
v0.37.15 delivers the plumbing — cancel, unclaim, reassign, stage CRUD
|
||||
in the FE, queue views. This series builds the product on top of that
|
||||
plumbing.
|
||||
|
||||
---
|
||||
|
||||
## Series Overview
|
||||
|
||||
```
|
||||
v0.37.15 Workflow Ownership & Lifecycle (plumbing)
|
||||
v0.37.16 Projects Surface
|
||||
v0.37.17 Debug / Model Surface
|
||||
│
|
||||
── 0.37 tag ──
|
||||
│
|
||||
v0.38.0 Form Builder ← team admins stop writing JSON
|
||||
v0.38.1 Stage Reorder + Bulk Ops ← queue management at scale
|
||||
v0.38.2 SLA Alerting ← stale queues self-report
|
||||
v0.38.3 Visitor Experience ← the public face
|
||||
v0.38.4 Workflow Templates + Clone ← reuse across teams
|
||||
v0.38.5 Analytics Dashboard ← throughput, bottlenecks, SLA compliance
|
||||
│
|
||||
─ ─ ─ MVP v0.50.0 gate ─ ─ ─
|
||||
```
|
||||
|
||||
**Milestone gates:**
|
||||
|
||||
| Milestone | Gate | Meaning |
|
||||
|-----------|------|---------|
|
||||
| v0.37 tag | **UI Complete** | Every platform capability has a surface; no features require raw API calls |
|
||||
| v0.38.0 | **Self-service** | A team admin can build a complete workflow without touching JSON or asking a global admin |
|
||||
| v0.38.2 | **Operational** | Stale work surfaces automatically; team admins can manage queues without monitoring dashboards |
|
||||
| v0.38.5 | **Measurable** | Team leads can answer "how long does stage X take" and "where are we bottlenecked" |
|
||||
|
||||
---
|
||||
|
||||
## v0.38.0 — Form Builder
|
||||
|
||||
**The single biggest gap.** `form_template` is currently raw JSON in a
|
||||
textarea. The MVP gate says "build workflows visually" — this is the
|
||||
make-or-break.
|
||||
|
||||
### Scope
|
||||
|
||||
A visual form builder embedded in the stage editor (E2 from the .15
|
||||
design). Not a standalone surface — it's a panel that opens when you
|
||||
click "Edit Form" on a `form_only` or `form_chat` stage.
|
||||
|
||||
### Fields supported (matching existing TypedFormTemplate)
|
||||
|
||||
`text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`,
|
||||
`file`. Each with the validation rules already defined in
|
||||
`models/workflow.go` (min/max length, pattern, min/max value, etc).
|
||||
|
||||
### JSX Exemplar
|
||||
|
||||
```jsx
|
||||
/**
|
||||
* FormBuilder — visual editor for stage form_template
|
||||
*
|
||||
* Opens as a slide-out panel from StageEditor.
|
||||
* Produces a TypedFormTemplate JSON blob on save.
|
||||
*
|
||||
* Features:
|
||||
* - Add/remove/reorder fields
|
||||
* - Field type picker with type-specific validation config
|
||||
* - Fieldset grouping (progressive multi-step forms)
|
||||
* - Conditional visibility (when/op/value)
|
||||
* - Live preview pane
|
||||
*/
|
||||
function FormBuilder({ initial, onSave, onCancel }) {
|
||||
const [fields, setFields] = useState(initial?.fields || []);
|
||||
const [fieldsets, setFieldsets] = useState(initial?.fieldsets || []);
|
||||
const [useFieldsets, setUseFieldsets] = useState((initial?.fieldsets?.length || 0) > 0);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
function addField(targetFieldset) {
|
||||
const field = {
|
||||
key: `field_${Date.now()}`,
|
||||
type: 'text',
|
||||
label: '',
|
||||
required: false,
|
||||
};
|
||||
if (useFieldsets && targetFieldset !== undefined) {
|
||||
// Add to specific fieldset
|
||||
setFieldsets(fs => fs.map((f, i) =>
|
||||
i === targetFieldset ? { ...f, fields: [...f.fields, field] } : f
|
||||
));
|
||||
} else {
|
||||
setFields(f => [...f, field]);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
const template = useFieldsets
|
||||
? { fieldsets, hooks: initial?.hooks || null }
|
||||
: { fields, hooks: initial?.hooks || null };
|
||||
onSave(template);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-builder">
|
||||
<div className="form-builder__header">
|
||||
<h4>Form Builder</h4>
|
||||
<label className="toggle-label">
|
||||
<input type="checkbox" checked={useFieldsets}
|
||||
onChange={e => setUseFieldsets(e.target.checked)} />
|
||||
Multi-step (fieldsets)
|
||||
</label>
|
||||
<button className="btn-small" onClick={() => setPreview(!preview)}>
|
||||
{preview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<FormPreview fields={fields} fieldsets={useFieldsets ? fieldsets : null} />
|
||||
) : (
|
||||
<div className="form-builder__canvas">
|
||||
{useFieldsets ? (
|
||||
<FieldsetEditor fieldsets={fieldsets} setFieldsets={setFieldsets} />
|
||||
) : (
|
||||
<FieldList fields={fields} setFields={setFields} />
|
||||
)}
|
||||
<button className="btn-small" onClick={() => addField()}>
|
||||
+ Add Field
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-builder__footer">
|
||||
<button className="btn-small btn-primary" onClick={save}>Save Form</button>
|
||||
<button className="btn-small" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldEditor — single field configuration row
|
||||
*
|
||||
* Inline editing of key, type, label, required, validation, condition.
|
||||
* Type-specific validation options appear contextually.
|
||||
*/
|
||||
function FieldEditor({ field, onChange, onRemove, allFields }) {
|
||||
function set(k, v) { onChange({ ...field, [k]: v }); }
|
||||
|
||||
return (
|
||||
<div className="field-editor">
|
||||
<div className="form-row">
|
||||
<div className="form-group" style={{ flex: 2 }}>
|
||||
<label>Label</label>
|
||||
<input value={field.label} onChange={e => set('label', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Key</label>
|
||||
<input value={field.key} onChange={e => set('key', e.target.value)}
|
||||
placeholder="field_name" />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Type</label>
|
||||
<select value={field.type} onChange={e => set('type', e.target.value)}>
|
||||
<option value="text">Text</option>
|
||||
<option value="textarea">Textarea</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="select">Select</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="file">File</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="toggle-label">
|
||||
<input type="checkbox" checked={field.required}
|
||||
onChange={e => set('required', e.target.checked)} />
|
||||
Required
|
||||
</label>
|
||||
|
||||
{/* Type-specific validation — only render what applies */}
|
||||
{(field.type === 'text' || field.type === 'textarea') && (
|
||||
<ValidationText validation={field.validation}
|
||||
onChange={v => set('validation', v)} />
|
||||
)}
|
||||
{field.type === 'number' && (
|
||||
<ValidationNumber validation={field.validation}
|
||||
onChange={v => set('validation', v)} />
|
||||
)}
|
||||
{field.type === 'select' && (
|
||||
<OptionsEditor options={field.options || []}
|
||||
onChange={o => set('options', o)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conditional visibility */}
|
||||
<ConditionEditor condition={field.condition}
|
||||
onChange={c => set('condition', c)}
|
||||
allFields={allFields} />
|
||||
|
||||
<button className="btn-small btn-danger" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### BE changes
|
||||
|
||||
None. The form builder is pure FE — it produces the same
|
||||
`TypedFormTemplate` JSON that already exists. The BE validation
|
||||
in `models/workflow.go` is unchanged.
|
||||
|
||||
### Deliverables
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `src/js/sw/components/form-builder/index.js` | FormBuilder root |
|
||||
| `src/js/sw/components/form-builder/field-editor.js` | FieldEditor, type-specific validators |
|
||||
| `src/js/sw/components/form-builder/fieldset-editor.js` | Fieldset grouping |
|
||||
| `src/js/sw/components/form-builder/condition-editor.js` | Conditional visibility |
|
||||
| `src/js/sw/components/form-builder/preview.js` | Live form preview |
|
||||
| `src/css/form-builder.css` | Styles |
|
||||
| Integration into StageEditor (E2 from .15) | "Edit Form" button opens builder |
|
||||
|
||||
---
|
||||
|
||||
## v0.38.1 — Stage Reorder + Bulk Ops
|
||||
|
||||
### Stage drag-and-drop
|
||||
|
||||
Replace button-based reorder with drag-and-drop in the stage editor.
|
||||
No external library — use the HTML5 Drag and Drop API with
|
||||
`draggable`, `ondragstart`, `ondragover`, `ondrop`.
|
||||
|
||||
The reorder PATCH endpoint already exists:
|
||||
`PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder`
|
||||
|
||||
### Bulk assignment operations
|
||||
|
||||
Team admins managing 20+ assignments need batch actions.
|
||||
|
||||
```jsx
|
||||
/**
|
||||
* BulkAssignmentBar — selection-based bulk actions
|
||||
*
|
||||
* Appears above the queue when 1+ assignments are selected.
|
||||
* Actions: bulk cancel, bulk reassign (to specific user).
|
||||
*/
|
||||
function BulkAssignmentBar({ selected, teamId, onAction }) {
|
||||
const [reassignTo, setReassignTo] = useState('');
|
||||
|
||||
async function bulkCancel() {
|
||||
const ok = await sw.confirm(`Cancel ${selected.length} assignments?`, true);
|
||||
if (!ok) return;
|
||||
await Promise.all(selected.map(id => sw.api.workflowAssignments.cancel(id)));
|
||||
sw.toast(`${selected.length} cancelled`, 'success');
|
||||
onAction();
|
||||
}
|
||||
|
||||
async function bulkReassign() {
|
||||
if (!reassignTo) return;
|
||||
await Promise.all(selected.map(id =>
|
||||
sw.api.workflowAssignments.reassign(id, reassignTo)
|
||||
));
|
||||
sw.toast(`${selected.length} reassigned`, 'success');
|
||||
onAction();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bulk-bar">
|
||||
<span>{selected.length} selected</span>
|
||||
<button className="btn-small btn-danger" onClick={bulkCancel}>Cancel All</button>
|
||||
<div className="bulk-bar__reassign">
|
||||
<TeamMemberPicker teamId={teamId} value={reassignTo}
|
||||
onChange={setReassignTo} />
|
||||
<button className="btn-small" onClick={bulkReassign}
|
||||
disabled={!reassignTo}>
|
||||
Reassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### BE changes
|
||||
|
||||
None. Bulk ops are client-side `Promise.all` over existing single
|
||||
endpoints. If performance becomes an issue at scale (50+ assignments),
|
||||
add a `POST /api/v1/workflow-assignments/bulk` endpoint in a future
|
||||
patch. YAGNI for now.
|
||||
|
||||
---
|
||||
|
||||
## v0.38.2 — SLA Alerting
|
||||
|
||||
The monitor already computes `sla_breached`. This version makes it
|
||||
proactive instead of requiring someone to check the dashboard.
|
||||
|
||||
### Scheduled SLA scanner
|
||||
|
||||
A new periodic task (Go scheduler, not Starlark) that runs every N
|
||||
minutes:
|
||||
|
||||
1. Query active workflow channels with `sla_seconds` configured
|
||||
2. Compute breach status from `stage_entered_at`
|
||||
3. For newly breached instances (not yet notified):
|
||||
- Create notification for the assigned team members
|
||||
- Emit `workflow.sla_breached` WS event
|
||||
- If webhook_url configured, fire webhook
|
||||
|
||||
### Data changes
|
||||
|
||||
Add `sla_notified_at` (nullable timestamp) to the channels table.
|
||||
The scanner only fires notifications when `sla_notified_at IS NULL`
|
||||
and the SLA is breached, then sets the timestamp. Prevents duplicate
|
||||
alerts.
|
||||
|
||||
### FE changes
|
||||
|
||||
The queue items (E1, E4) already render `sla_breached` badges. Add:
|
||||
- Pulsing animation on breached badges
|
||||
- Toast on `workflow.sla_breached` WS event
|
||||
- Notification bell integration (already wired in .15 via
|
||||
`notifications.NotifyWorkflowClaimed` pattern)
|
||||
|
||||
### Deliverables
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `server/scheduler/sla_scanner.go` | Periodic SLA check |
|
||||
| `server/notifications/workflow_sla.go` | SLA notification builder |
|
||||
| Migration: `sla_notified_at` column | Both PG + SQLite |
|
||||
| FE: badge animation CSS | `src/css/sw-primitives.css` |
|
||||
|
||||
---
|
||||
|
||||
## v0.38.3 — Visitor Experience
|
||||
|
||||
The visitor currently gets a raw workflow surface. After .15 they get
|
||||
a terminal screen (E5) when their stages are done. This version makes
|
||||
the visitor experience feel like a product.
|
||||
|
||||
### Scope
|
||||
|
||||
1. **Branded entry page** — the workflow landing page already supports
|
||||
branding (`accent_color`, `logo_url`, `tagline`). Extend with:
|
||||
- Custom welcome text (markdown)
|
||||
- Background image/gradient
|
||||
- "Powered by" toggle (hide/show Switchboard branding)
|
||||
|
||||
2. **Progress indicator** — visitor sees "Step 1 of N" for their
|
||||
visible stages only. Internal team stages are invisible. The
|
||||
progress bar counts only stages where the visitor is the audience.
|
||||
|
||||
3. **Email notifications** — optional. When configured on the workflow:
|
||||
- Collect email at stage 0 (or extract from form data)
|
||||
- Send "request received" confirmation
|
||||
- Send "your request is complete" when workflow completes
|
||||
- No notifications for internal stage transitions
|
||||
|
||||
4. **Session resume** — visitor who closes the browser can return to
|
||||
`/w/:id/:slug` and resume where they left off. Already partially
|
||||
implemented via `sb_session` cookie. Polish the UX: show "Welcome
|
||||
back, pick up where you left off" instead of starting fresh.
|
||||
|
||||
### BE changes
|
||||
|
||||
```go
|
||||
// Workflow model additions:
|
||||
type WorkflowBranding struct {
|
||||
AccentColor string `json:"accent_color"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
WelcomeText string `json:"welcome_text"` // NEW: markdown
|
||||
BackgroundCSS string `json:"background_css"` // NEW: gradient or image URL
|
||||
HidePoweredBy bool `json:"hide_powered_by"` // NEW
|
||||
}
|
||||
|
||||
// Email notification config (in workflow settings):
|
||||
type WorkflowEmailConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
FromName string `json:"from_name"`
|
||||
OnSubmit string `json:"on_submit_template"` // markdown template
|
||||
OnComplete string `json:"on_complete_template"` // markdown template
|
||||
}
|
||||
```
|
||||
|
||||
Email sending requires an SMTP config at the platform level
|
||||
(admin settings). If not configured, email toggle is disabled in
|
||||
the workflow editor with a hint.
|
||||
|
||||
### JSX Exemplar
|
||||
|
||||
```jsx
|
||||
/**
|
||||
* VisitorProgress — step indicator for visitor-facing stages
|
||||
*
|
||||
* Mount: workflow surface, above the stage content
|
||||
* Only counts stages where audience = visitor.
|
||||
*/
|
||||
function VisitorProgress({ stages, currentStage }) {
|
||||
// Filter to visitor-facing stages only
|
||||
const visitorStages = stages.filter(s => !s.assignment_team_id);
|
||||
const visitorIndex = visitorStages.findIndex(s => s.ordinal === currentStage);
|
||||
const total = visitorStages.length;
|
||||
|
||||
if (total <= 1) return null; // Single stage = no progress bar
|
||||
|
||||
return (
|
||||
<div className="visitor-progress">
|
||||
{visitorStages.map((s, i) => (
|
||||
<div key={s.id}
|
||||
className={`visitor-progress__step ${
|
||||
i < visitorIndex ? 'done' :
|
||||
i === visitorIndex ? 'active' : ''
|
||||
}`}>
|
||||
<div className="visitor-progress__dot">{i < visitorIndex ? '✓' : i + 1}</div>
|
||||
<span className="visitor-progress__label">{s.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.38.4 — Workflow Templates + Clone
|
||||
|
||||
Team admins shouldn't rebuild common patterns from scratch. Two
|
||||
features:
|
||||
|
||||
### Clone workflow
|
||||
|
||||
"Duplicate" button on the workflow editor. Creates a new workflow with
|
||||
the same stages, form templates, and routing rules. New slug, new name
|
||||
(appended "- Copy"). No versions carried over (must re-publish).
|
||||
|
||||
```
|
||||
POST /api/v1/teams/:teamId/workflows/:id/clone
|
||||
→ 201 { ...newWorkflow }
|
||||
```
|
||||
|
||||
Pure BE operation — deep-copies workflow + stages, generates new IDs.
|
||||
|
||||
### Built-in templates
|
||||
|
||||
Seed a `workflow_templates` table with 3-5 common patterns:
|
||||
|
||||
| Template | Stages | Description |
|
||||
|----------|--------|-------------|
|
||||
| Customer Intake | form → review | Public form, team reviews |
|
||||
| IT Help Desk | LLM chat → triage → resolution | AI-assisted intake |
|
||||
| Approval Chain | form → approve → approve → notify | Multi-level approval |
|
||||
| Feedback Survey | form (multi-step) → analytics | Data collection |
|
||||
| Onboarding | form → team A → team B → team A | Multi-team handoff |
|
||||
|
||||
"New from template" in the team-admin workflow creation flow.
|
||||
Templates are read-only platform data, not user-editable. They
|
||||
serve as starting points — clone and customize.
|
||||
|
||||
### FE changes
|
||||
|
||||
- "Duplicate" button in workflow editor header
|
||||
- "New from Template" option in workflow creation flow
|
||||
- Template picker modal with descriptions
|
||||
|
||||
---
|
||||
|
||||
## v0.38.5 — Analytics Dashboard
|
||||
|
||||
Team leads need to answer: how long does each stage take, where are
|
||||
we bottlenecked, what's our SLA compliance rate.
|
||||
|
||||
### Data source
|
||||
|
||||
No new data collection. Everything is derivable from existing columns:
|
||||
|
||||
- `channels.created_at` — instance start time
|
||||
- `channels.stage_entered_at` — current stage entry
|
||||
- `channels.workflow_status` — terminal state
|
||||
- `workflow_assignments.created_at / claimed_at / completed_at` — assignment lifecycle timestamps
|
||||
|
||||
### Metrics
|
||||
|
||||
| Metric | Derivation |
|
||||
|--------|------------|
|
||||
| Throughput | Count of `completed` instances per time period |
|
||||
| Average cycle time | `completed_at - created_at` across instances |
|
||||
| Stage dwell time | `next_stage_entered_at - stage_entered_at` (requires computing from history) |
|
||||
| SLA compliance | `breached / total` per stage with SLA configured |
|
||||
| Assignment claim time | `claimed_at - created_at` on assignments |
|
||||
| Queue depth over time | Snapshot of unassigned count (requires periodic sampling or derive from events) |
|
||||
|
||||
### JSX Exemplar
|
||||
|
||||
```jsx
|
||||
/**
|
||||
* WorkflowAnalytics — team-admin analytics tab
|
||||
*
|
||||
* Mount: team-admin workflows surface, "Analytics" tab
|
||||
* Data: GET /api/v1/teams/:teamId/workflows/analytics
|
||||
* ?workflow_id=...&period=7d|30d|90d
|
||||
*/
|
||||
function WorkflowAnalytics({ teamId }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [workflowId, setWorkflowId] = useState('');
|
||||
const [period, setPeriod] = useState('30d');
|
||||
|
||||
// ... load, filter controls ...
|
||||
|
||||
return (
|
||||
<div className="wf-analytics">
|
||||
<div className="wf-analytics__filters">
|
||||
<WorkflowPicker teamId={teamId} value={workflowId} onChange={setWorkflowId} />
|
||||
<PeriodPicker value={period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="wf-analytics__cards">
|
||||
<StatCard label="Completed" value={data.completed_count} />
|
||||
<StatCard label="Avg Cycle Time" value={formatDuration(data.avg_cycle_seconds)} />
|
||||
<StatCard label="SLA Compliance" value={`${data.sla_compliance_pct}%`}
|
||||
variant={data.sla_compliance_pct < 80 ? 'danger' : 'success'} />
|
||||
<StatCard label="Avg Claim Time" value={formatDuration(data.avg_claim_seconds)} />
|
||||
</div>
|
||||
|
||||
{/* Stage funnel with dwell times — uses existing funnel endpoint + dwell data */}
|
||||
<StageFunnel stages={data.stage_metrics} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### BE changes
|
||||
|
||||
New endpoint:
|
||||
```
|
||||
GET /api/v1/teams/:teamId/workflows/analytics
|
||||
?workflow_id=...&period=7d|30d|90d
|
||||
```
|
||||
|
||||
Aggregation query over channels + assignments tables. No new tables.
|
||||
Consider materialized views or periodic snapshot if query cost is
|
||||
too high on large datasets (unlikely at 50-user scale).
|
||||
|
||||
---
|
||||
|
||||
## Changeset Budget
|
||||
|
||||
| Version | Estimated CS | Heaviest piece |
|
||||
|---------|-------------|----------------|
|
||||
| v0.38.0 | 4 | Form builder component tree (5+ files) |
|
||||
| v0.38.1 | 2 | Drag-and-drop is fiddly but small |
|
||||
| v0.38.2 | 3 | SLA scanner + notification plumbing |
|
||||
| v0.38.3 | 4 | Visitor branding + email + progress |
|
||||
| v0.38.4 | 2 | Clone is one BE endpoint + FE button |
|
||||
| v0.38.5 | 3 | Analytics query + chart components |
|
||||
| **Total** | **~18** | |
|
||||
|
||||
---
|
||||
|
||||
## Risk Notes
|
||||
|
||||
**v0.38.0 (Form Builder) is the long pole.** If this slips, the MVP
|
||||
gate phrase "build workflows visually" fails. Prioritize this over
|
||||
everything else in the series. If time is tight, ship a simplified
|
||||
version (no fieldsets, no conditional visibility) and add those in a
|
||||
patch.
|
||||
|
||||
**v0.38.3 (Email) depends on platform SMTP.** If SMTP isn't
|
||||
configured, the feature is invisible. Consider making the email
|
||||
config a prerequisite admin step with a setup wizard, or defer email
|
||||
to v0.38.5 and keep .3 focused on branding + progress.
|
||||
|
||||
**v0.38.5 (Analytics) can be cut.** If the series is running long,
|
||||
analytics is the most deferrable item — it's nice-to-have, not
|
||||
gate-blocking. The existing monitor + funnel endpoints cover the
|
||||
critical "is anything stuck" question.
|
||||
Reference in New Issue
Block a user