diff --git a/CHANGELOG.md b/CHANGELOG.md index 31618dc..5ab78c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to Armature are documented here. +## v0.7.10 — Workflow Handoff + Assignment UI + +Closes the three UX gaps found during v0.7.9: public→team handoff, +team inbox, and manual assignment. Also fixes dead system-admin bypass +in team middleware and enriches assignment API responses. + +**Public Stage Handoff** + +- `RenderWorkflow()` detects audience mismatch (team/system stage + unauthenticated visitor) and renders "Submitted Successfully" screen with reference ID instead of showing the team-gated form. + +**API Response Enrichment** + +- `ListByTeam` and `ListMine` handlers enrich assignment records with `workflow_name`, `stage_name`, `sla_breached` by joining instance → workflow → version snapshot. Results cached per-request to avoid repeated DB hits. +- `ListTeamInstances` returns `instanceView` with `workflow_name`, `stage_name`, `age_seconds`, `sla_breached`. + +**Team Middleware Fix** + +- `RequireTeamAdmin` / `RequireTeamMember` system-admin bypass was dead code (`c.Get("role")` never set by auth middleware). Fixed to resolve `PermSurfaceAdminAccess` via `resolveAndCachePerms`. Variadic `allStores` parameter preserves backward compatibility. + +**Team Workflow Inbox** + +- Enhanced `AssignmentsTab` in team-admin: "My Active" (claimed) and "Available" (unassigned) sections with claim/unclaim/release/work/complete actions, time-ago display, WS live updates. +- Manual "Assign" button on unassigned rows with team member dropdown. New `POST /api/v1/assignments/:id/assign` endpoint. + +**Assignment Notifications** + +- Engine calls `notifyAssignment()` on assignment creation — notifies specific user or all team members via notification system. + +**SDK Gap Closure** + +- Added `workflowAssignments` domain (claim/unclaim/complete/cancel/assign/mine) +- Added `teams.assignments`, `teams.workflowInstances`, `teams.cancelWorkflowInstance` +- Fixed dead `workflows.cancel` route referencing `/channels/` + +**E2E Test** + +- `ci/e2e-workflow-handoff.sh`: public form → team review → claim → complete. Verifies audience mismatch screen, assignment creation, full lifecycle. + +--- + ## v0.7.9 — Workflow Independence Audit Workflows proven independent of chat and all optional packages. Critical diff --git a/ROADMAP.md b/ROADMAP.md index 9dd3fc4..f744d66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Armature — Roadmap -## Current: v0.7.9 — Workflow Independence Audit +## Current: v0.7.10 — Workflow Handoff + Assignment UI Self-hosted extensible platform kernel. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else @@ -331,11 +331,15 @@ missing pieces are page-level audience detection and team-facing UI. | Step | Status | Description | |------|--------|-------------| -| Public stage completion screen | | When `RenderWorkflow()` detects the current stage's audience is `team`/`system` but the visitor is unauthenticated, render a "Submitted — your request is being reviewed" screen instead of the stage form. | -| Team workflow inbox | | New section in team-admin (or dedicated surface): list active instances assigned to this team, with claim/unclaim actions. Uses existing `ListAssignmentsByTeam` store method. | -| Manual assignment UI | | Team admin can assign an unclaimed instance to a specific team member. Uses existing `CreateAssignment` store method. | -| Assignment notifications | | When an instance is assigned (auto or manual), notify the assignee via the notification system. | -| E2E multi-stage test | | Full lifecycle: public form → team pickup → review → complete. Verified with both authenticated and unauthenticated users. | +| Public stage completion screen | done | `RenderWorkflow()` detects audience mismatch (team/system stage + unauthenticated visitor) and renders "Submitted Successfully" screen with reference ID. | +| Team workflow inbox | done | Enhanced `AssignmentsTab` in team-admin: claim/unclaim/work actions, time-ago display, WS live updates. Fixed vestigial `channel_id` references. | +| Manual assignment UI | done | "Assign" button on unassigned rows with team member dropdown. New `POST /api/v1/assignments/:id/assign` endpoint (requires `workflow.create` permission). | +| Assignment notifications | done | Engine calls `notifyAssignment()` on assignment creation — notifies specific user or all team members via notification system. | +| Team workflow instances endpoint | done | `GET /api/v1/teams/:teamId/workflow-instances` + cancel endpoint. `ListInstancesByTeam` store method (SQLite + PG). SDK methods added. | +| API response enrichment | done | `ListByTeam` and `ListMine` handlers enrich assignments with `workflow_name`, `stage_name`, `sla_breached` by joining instance → workflow → version snapshot. Cached per-request. Same for `ListTeamInstances` with `instanceView` struct. | +| Team middleware fix | done | `RequireTeamAdmin` / `RequireTeamMember` system-admin bypass was dead code (`c.Get("role")` never set). Fixed to check `PermSurfaceAdminAccess` via `resolveAndCachePerms`. | +| SDK gap closure | done | Added `workflowAssignments` domain (claim/unclaim/complete/cancel/assign/mine), `teams.assignments`, `teams.workflowInstances`, `teams.cancelWorkflowInstance`. Fixed dead `workflows.cancel` route. | +| E2E multi-stage test | done | `ci/e2e-workflow-handoff.sh`: public form → team review → claim → complete. Verifies audience mismatch screen, assignment creation, full lifecycle. | ### v0.7.11 — Query & HTTP Ergonomics (was v0.7.10) diff --git a/VERSION b/VERSION index 879be8a..5b209ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.7 +0.7.10 diff --git a/ci/e2e-workflow-handoff.sh b/ci/e2e-workflow-handoff.sh new file mode 100755 index 0000000..4c08cd4 --- /dev/null +++ b/ci/e2e-workflow-handoff.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════ +# E2E Workflow Handoff Test +# ═══════════════════════════════════════════════ +# +# Verifies the public→team handoff flow: +# 1. Public visitor completes a form stage +# 2. Visitor sees "submitted" screen (not the team stage) +# 3. Team user sees assignment, claims it, completes review +# 4. Instance status is "completed" +# +# Prerequisites: +# - Server running at $SERVER_URL (default: http://localhost:3000) +# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin) +# +# Exit codes: 0 = all assertions passed, 1 = failures detected +set -euo pipefail + +SERVER_URL="${SERVER_URL:-http://localhost:3000}" +ADMIN_USER="${ADMIN_USER:-admin}" +ADMIN_PASS="${ADMIN_PASS:-admin}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +PASS=0 +FAIL=0 + +assert() { + local desc="$1" ok="$2" + if [ "$ok" = "true" ]; then + echo -e " ${GREEN}✓${NC} $desc" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $desc" + FAIL=$((FAIL + 1)) + fi +} + +# Parse JSON field via node (portable) +json_field() { + node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d)$1||'')}catch(e){console.log('')}})" 2>/dev/null +} + +echo -e "${YELLOW}═══ E2E Workflow Handoff Test ═══${NC}" +echo " Server: ${SERVER_URL}" + +# ── Authenticate ───────────────────────────── +TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \ + | json_field ".token" || true) + +if [ -z "$TOKEN" ]; then + echo -e "${RED}Failed to authenticate${NC}" + exit 1 +fi +echo -e "${GREEN}Authenticated${NC}" + +AUTH="Authorization: Bearer ${TOKEN}" + +# Get admin user ID +USER_ID=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/profile" | json_field ".id") +assert "Got admin user ID" "$([ -n "$USER_ID" ] && echo true || echo false)" + +# ── Phase 1: Create team ───────────────────── +echo -e "\n${YELLOW}Phase 1: Create test team${NC}" + +TEAM_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/admin/teams" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name": "E2E Handoff Team", "slug": "e2e-handoff-team"}' 2>/dev/null || echo '{}') + +TEAM_ID=$(echo "$TEAM_RESP" | json_field ".id") +assert "Team created" "$([ -n "$TEAM_ID" ] && echo true || echo false)" + +# Add admin as team member +if [ -n "$TEAM_ID" ] && [ -n "$USER_ID" ]; then + curl -sf -X POST "${SERVER_URL}/api/v1/teams/${TEAM_ID}/members" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"user_id\":\"${USER_ID}\",\"role\":\"admin\"}" >/dev/null 2>&1 || true +fi + +# ── Phase 2: Create workflow with public + team stages ── +echo -e "\n${YELLOW}Phase 2: Create workflow (public form → team review)${NC}" + +WF_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "name": "E2E Handoff Test", + "slug": "e2e-handoff-test", + "description": "Tests public to team handoff", + "entry_mode": "public_link", + "is_active": true + }' 2>/dev/null || echo '{"error":"failed"}') + +WF_ID=$(echo "$WF_RESP" | json_field ".id") +assert "Workflow created" "$([ -n "$WF_ID" ] && echo true || echo false)" + +if [ -z "$WF_ID" ]; then + echo -e "${RED}Cannot continue without workflow ID${NC}" + exit 1 +fi + +# Stage 1: public form +STAGE1_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "name": "Customer Request", + "stage_mode": "form", + "audience": "public", + "ordinal": 0, + "form_template": { + "fields": [ + {"key": "name", "type": "text", "label": "Your Name", "required": true}, + {"key": "request", "type": "textarea", "label": "Request Details"} + ] + } + }' 2>/dev/null || echo '{}') + +STAGE1_ID=$(echo "$STAGE1_RESP" | json_field ".id") +assert "Public form stage created" "$([ -n "$STAGE1_ID" ] && echo true || echo false)" + +# Stage 2: team review (with assignment) +STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Team Review\", + \"stage_mode\": \"review\", + \"audience\": \"team\", + \"ordinal\": 1, + \"assignment_team_id\": \"${TEAM_ID}\" + }" 2>/dev/null || echo '{}') + +STAGE2_ID=$(echo "$STAGE2_RESP" | json_field ".id") +assert "Team review stage created" "$([ -n "$STAGE2_ID" ] && echo true || echo false)" + +# ── Phase 3: Public visitor starts and completes form ── +echo -e "\n${YELLOW}Phase 3: Public visitor submits form${NC}" + +START_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflow-entry/global/e2e-handoff-test" \ + -H "Content-Type: application/json" \ + -d '{}' 2>/dev/null || echo '{"error":"failed"}') + +INST_ID=$(echo "$START_RESP" | json_field ".id") +ENTRY_TOKEN=$(echo "$START_RESP" | json_field ".entry_token") + +assert "Instance created" "$([ -n "$INST_ID" ] && echo true || echo false)" +assert "Entry token present" "$([ -n "$ENTRY_TOKEN" ] && echo true || echo false)" + +# Submit form data (advance past stage 1) +if [ -n "$ENTRY_TOKEN" ]; then + ADV_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/public/workflows/advance/${ENTRY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"data": {"name": "Test User", "request": "Please review this"}}' 2>/dev/null || echo '{}') + + ADV_STATUS=$(echo "$ADV_RESP" | json_field ".status") + assert "Instance advanced to team stage (status=active)" "$([ "$ADV_STATUS" = "active" ] && echo true || echo false)" +fi + +# ── Phase 4: Verify audience mismatch screen ── +echo -e "\n${YELLOW}Phase 4: Verify visitor sees submitted screen${NC}" + +if [ -n "$INST_ID" ]; then + # Fetch the workflow page without auth (public visitor) + WF_PAGE=$(curl -sf "${SERVER_URL}/w/${INST_ID}" 2>/dev/null || echo "") + HAS_SUBMITTED=$(echo "$WF_PAGE" | grep -c "Submitted Successfully" || true) + assert "Page shows 'Submitted Successfully'" "$([ "$HAS_SUBMITTED" -gt 0 ] && echo true || echo false)" + + HAS_FORM=$(echo "$WF_PAGE" | grep -c 'id="formArea"' || true) + assert "Page does NOT show form area" "$([ "$HAS_FORM" -eq 0 ] && echo true || echo false)" +fi + +# ── Phase 5: Team user sees assignment ───────── +echo -e "\n${YELLOW}Phase 5: Team assignment appears${NC}" + +if [ -n "$TEAM_ID" ]; then + ASSIGN_RESP=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/teams/${TEAM_ID}/assignments?status=unassigned" 2>/dev/null || echo '{}') + ASSIGN_COUNT=$(echo "$ASSIGN_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const r=JSON.parse(d); console.log((r.data||r).length||0)}catch(e){console.log(0)}})" 2>/dev/null) + assert "Unassigned assignment exists" "$([ "$ASSIGN_COUNT" -gt 0 ] && echo true || echo false)" + + # Get assignment ID + ASSIGN_ID=$(echo "$ASSIGN_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const r=JSON.parse(d); console.log((r.data||r)[0].id||'')}catch(e){console.log('')}})" 2>/dev/null) +fi + +# ── Phase 6: Claim and complete assignment ───── +echo -e "\n${YELLOW}Phase 6: Claim and complete${NC}" + +if [ -n "$ASSIGN_ID" ]; then + # Claim + CLAIM_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/assignments/${ASSIGN_ID}/claim" \ + -H "$AUTH" -H "Content-Type: application/json" -d '{}' 2>/dev/null || echo '{}') + CLAIMED=$(echo "$CLAIM_RESP" | json_field ".claimed") + assert "Assignment claimed" "$([ "$CLAIMED" = "true" ] && echo true || echo false)" + + # Complete (advance the review stage) + COMPLETE_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/assignments/${ASSIGN_ID}/complete" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"review_data": {"decision": "approved", "comment": "Looks good"}}' 2>/dev/null || echo '{}') + COMPLETED=$(echo "$COMPLETE_RESP" | json_field ".completed") + assert "Assignment completed" "$([ "$COMPLETED" = "true" ] && echo true || echo false)" +fi + +# ── Phase 7: Verify instance is completed ────── +echo -e "\n${YELLOW}Phase 7: Verify final state${NC}" + +if [ -n "$INST_ID" ]; then + FINAL_RESP=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/workflows/${WF_ID}/instances/${INST_ID}" 2>/dev/null || echo '{}') + FINAL_STATUS=$(echo "$FINAL_RESP" | json_field ".status") + assert "Instance status is completed" "$([ "$FINAL_STATUS" = "completed" ] && echo true || echo false)" +fi + +# ── Cleanup ────────────────────────────────── +echo -e "\n${YELLOW}Cleanup${NC}" + +curl -sf -X DELETE "${SERVER_URL}/api/v1/workflows/${WF_ID}" -H "$AUTH" >/dev/null 2>&1 || true +if [ -n "$TEAM_ID" ]; then + curl -sf -X DELETE "${SERVER_URL}/api/v1/admin/teams/${TEAM_ID}" -H "$AUTH" >/dev/null 2>&1 || true +fi +echo -e " Deleted test resources" + +# ── Summary ────────────────────────────────── +echo "" +TOTAL=$((PASS + FAIL)) +if [ $FAIL -eq 0 ]; then + echo -e "${GREEN}═══ All ${TOTAL} assertions passed ═══${NC}" + exit 0 +else + echo -e "${RED}═══ ${FAIL}/${TOTAL} assertions failed ═══${NC}" + exit 1 +fi diff --git a/docker-compose.yml b/docker-compose.yml index e189bb6..c67e6a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true} LOG_FORMAT: ${LOG_FORMAT:-text} LOG_LEVEL: ${LOG_LEVEL:-info} - BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-*} + BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-} # Dev seed users — ignored if ENVIRONMENT=production SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user} volumes: diff --git a/server/handlers/workflow_assignment_handlers.go b/server/handlers/workflow_assignment_handlers.go index d6280b9..8992145 100644 --- a/server/handlers/workflow_assignment_handlers.go +++ b/server/handlers/workflow_assignment_handlers.go @@ -1,9 +1,11 @@ package handlers import ( + "context" "database/sql" "encoding/json" "net/http" + "time" "github.com/gin-gonic/gin" @@ -12,6 +14,15 @@ import ( "armature/workflow" ) +// assignmentView is the enriched response for assignment list endpoints. +type assignmentView struct { + models.WorkflowAssignment + WorkflowID string `json:"workflow_id"` + WorkflowName string `json:"workflow_name"` + StageName string `json:"stage_name"` + SLABreached bool `json:"sla_breached"` +} + // ── Assignment Handlers ───────────────────── // WorkflowAssignmentHandler manages workflow assignment HTTP endpoints. @@ -147,7 +158,7 @@ func (h *WorkflowAssignmentHandler) Cancel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"cancelled": true}) } -// ListByTeam returns assignments for a team. +// ListByTeam returns enriched assignments for a team. // GET /api/v1/teams/:teamId/assignments?status= func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) { teamID := c.Param("teamId") @@ -156,20 +167,50 @@ func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) { result, err := h.stores.Workflows.ListAssignmentsByTeam(c.Request.Context(), teamID, status) if err != nil { if err == sql.ErrNoRows { - c.JSON(http.StatusOK, gin.H{"data": []struct{}{}}) + c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } if result == nil { - c.JSON(http.StatusOK, gin.H{"data": []struct{}{}}) + c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } - c.JSON(http.StatusOK, gin.H{"data": result}) + c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)}) } -// ListMine returns assignments claimed by or assigned to the current user. +// Assign lets a team admin assign an unassigned assignment to a specific user. +// POST /api/v1/assignments/:id/assign +func (h *WorkflowAssignmentHandler) Assign(c *gin.Context) { + assignmentID := c.Param("id") + ctx := c.Request.Context() + + var body struct { + UserID string `json:"user_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) + return + } + + // Verify the target user exists + targetUser, err := h.stores.Users.GetByID(ctx, body.UserID) + if err != nil || targetUser == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) + return + } + + // ClaimAssignment sets assigned_to and status=claimed — works for admin assignment + if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, body.UserID); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"assigned": true, "assigned_to": body.UserID}) +} + +// ListMine returns enriched assignments claimed by or assigned to the current user. // GET /api/v1/assignments/mine?status= func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { userID := c.GetString("user_id") @@ -181,8 +222,80 @@ func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { return } if result == nil { - c.JSON(http.StatusOK, gin.H{"data": []struct{}{}}) + c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } - c.JSON(http.StatusOK, gin.H{"data": result}) + c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)}) +} + +// enrichAssignments resolves workflow_name, stage_name, and sla_breached +// for each assignment by looking up instance → workflow → stage snapshot. +func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assignments []models.WorkflowAssignment) []assignmentView { + views := make([]assignmentView, 0, len(assignments)) + + // Cache lookups to avoid repeated DB hits for the same workflow/instance + instanceCache := map[string]*models.WorkflowInstance{} + workflowCache := map[string]*models.Workflow{} + + for _, a := range assignments { + v := assignmentView{WorkflowAssignment: a} + + // Resolve instance → workflow + inst, ok := instanceCache[a.InstanceID] + if !ok { + inst, _ = h.stores.Workflows.GetInstance(ctx, a.InstanceID) + instanceCache[a.InstanceID] = inst + } + + if inst != nil { + v.WorkflowID = inst.WorkflowID + + wf, ok := workflowCache[inst.WorkflowID] + if !ok { + wf, _ = h.stores.Workflows.GetByID(ctx, inst.WorkflowID) + workflowCache[inst.WorkflowID] = wf + } + if wf != nil { + v.WorkflowName = wf.Name + } + + // Resolve stage name and SLA from the published version snapshot + ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) + if ver != nil { + stages := parseSnapshotStagesForEnrich(ver.Snapshot) + for _, s := range stages { + if s.Name == a.Stage { + v.StageName = s.Name + if s.SLASeconds != nil && *s.SLASeconds > 0 { + elapsed := time.Since(a.CreatedAt) + v.SLABreached = elapsed.Seconds() > float64(*s.SLASeconds) + } + break + } + } + } + } + + // Fallback: use stage field as stage_name if not resolved + if v.StageName == "" { + v.StageName = a.Stage + } + + views = append(views, v) + } + + return views +} + +// parseSnapshotStagesForEnrich is a lightweight stage parser for enrichment. +func parseSnapshotStagesForEnrich(snapshot json.RawMessage) []models.WorkflowStage { + var wrapped struct { + Stages []models.WorkflowStage `json:"stages"` + } + if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 { + return wrapped.Stages + } + var stages []models.WorkflowStage + json.Unmarshal(snapshot, &stages) + return stages } diff --git a/server/handlers/workflow_instance_handlers.go b/server/handlers/workflow_instance_handlers.go index a0ff373..d287c5d 100644 --- a/server/handlers/workflow_instance_handlers.go +++ b/server/handlers/workflow_instance_handlers.go @@ -1,17 +1,34 @@ package handlers import ( + "context" "database/sql" "encoding/json" "net/http" "strconv" + "time" "github.com/gin-gonic/gin" + "armature/models" "armature/store" "armature/workflow" ) +// instanceView is the enriched response for team instance list endpoints. +type instanceView struct { + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + WorkflowName string `json:"workflow_name"` + CurrentStage string `json:"current_stage"` + StageName string `json:"stage_name"` + Status string `json:"status"` + StartedBy string `json:"started_by"` + AgeSeconds int `json:"age_seconds"` + SLABreached bool `json:"sla_breached"` + CreatedAt string `json:"created_at"` +} + // ── Instance Handlers ─────────────────────── // WorkflowInstanceHandler manages workflow instance HTTP endpoints. @@ -120,6 +137,123 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { c.JSON(http.StatusOK, inst) } +// ListTeamInstances returns enriched instances for all workflows owned by a team. +// GET /api/v1/teams/:teamId/workflow-instances?status=&limit=&offset= +func (h *WorkflowInstanceHandler) ListTeamInstances(c *gin.Context) { + teamID := c.Param("teamId") + status := c.Query("status") + if status == "" { + status = "active" // default to active for monitoring + } + + opts := store.ListOptions{} + if l := c.Query("limit"); l != "" { + opts.Limit, _ = strconv.Atoi(l) + } + if o := c.Query("offset"); o != "" { + opts.Offset, _ = strconv.Atoi(o) + } + if opts.Limit == 0 { + opts.Limit = 50 + } + + result, err := h.stores.Workflows.ListInstancesByTeam(c.Request.Context(), teamID, status, opts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list instances"}) + return + } + if result == nil { + c.JSON(http.StatusOK, gin.H{"data": []instanceView{}}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h.enrichInstances(c.Request.Context(), result)}) +} + +// enrichInstances resolves workflow_name, stage_name, sla_breached, and age +// for each instance by looking up the workflow definition and version snapshot. +func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances []models.WorkflowInstance) []instanceView { + views := make([]instanceView, 0, len(instances)) + workflowCache := map[string]*models.Workflow{} + + for _, inst := range instances { + v := instanceView{ + ID: inst.ID, + WorkflowID: inst.WorkflowID, + CurrentStage: inst.CurrentStage, + StageName: inst.CurrentStage, // default + Status: inst.Status, + StartedBy: inst.StartedBy, + AgeSeconds: int(time.Since(inst.CreatedAt).Seconds()), + CreatedAt: inst.CreatedAt.Format(time.RFC3339), + } + + wf, ok := workflowCache[inst.WorkflowID] + if !ok { + wf, _ = h.stores.Workflows.GetByID(ctx, inst.WorkflowID) + workflowCache[inst.WorkflowID] = wf + } + if wf != nil { + v.WorkflowName = wf.Name + } + + // SLA check from version snapshot + ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) + if ver != nil { + stages := parseSnapshotStagesForView(ver.Snapshot) + for _, s := range stages { + if s.Name == inst.CurrentStage { + v.StageName = s.Name + if s.SLASeconds != nil && *s.SLASeconds > 0 { + elapsed := time.Since(inst.StageEnteredAt) + v.SLABreached = elapsed.Seconds() > float64(*s.SLASeconds) + } + break + } + } + } + + views = append(views, v) + } + return views +} + +func parseSnapshotStagesForView(snapshot json.RawMessage) []models.WorkflowStage { + var wrapped struct { + Stages []models.WorkflowStage `json:"stages"` + } + if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 { + return wrapped.Stages + } + var stages []models.WorkflowStage + json.Unmarshal(snapshot, &stages) + return stages +} + +// CancelTeamInstance cancels an instance belonging to a team workflow. +// POST /api/v1/teams/:teamId/workflow-instances/:iid/cancel +func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) { + userID := c.GetString("user_id") + instanceID := c.Param("iid") + + // Verify instance belongs to a workflow owned by this team + inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), instanceID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"}) + return + } + wf, err := h.stores.Workflows.GetByID(c.Request.Context(), inst.WorkflowID) + if err != nil || wf.TeamID == nil || *wf.TeamID != c.Param("teamId") { + c.JSON(http.StatusForbidden, gin.H{"error": "instance does not belong to this team"}) + return + } + + if err := h.engine.Cancel(c.Request.Context(), instanceID, userID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"cancelled": true}) +} + // Cancel terminates an active instance. // POST /api/v1/workflows/:id/instances/:iid/cancel func (h *WorkflowInstanceHandler) Cancel(c *gin.Context) { diff --git a/server/main.go b/server/main.go index ff36656..a01d06d 100644 --- a/server/main.go +++ b/server/main.go @@ -519,6 +519,7 @@ func main() { protected.POST("/assignments/:id/unclaim", wfAssignH.Unclaim) protected.POST("/assignments/:id/complete", wfAssignH.Complete) protected.POST("/assignments/:id/cancel", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfAssignH.Cancel) + protected.POST("/assignments/:id/assign", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfAssignH.Assign) protected.GET("/assignments/mine", wfAssignH.ListMine) // Workflow signoffs @@ -615,7 +616,7 @@ func main() { // Team admin self-service teamScoped := protected.Group("/teams/:teamId") - teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams)) + teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams, stores)) { teamScoped.GET("/members", teams.ListMembers) teamScoped.POST("/members", teams.AddMember) @@ -677,6 +678,10 @@ func main() { teamWfAssignH := handlers.NewWorkflowAssignmentHandler(wfEngine, stores) teamScoped.GET("/assignments", teamWfAssignH.ListByTeam) + // Team workflow instances (cross-workflow) + teamScoped.GET("/workflow-instances", teamWfInstH.ListTeamInstances) + teamScoped.POST("/workflow-instances/:iid/cancel", teamWfInstH.CancelTeamInstance) + // Team workflow signoffs teamWfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores) teamScoped.POST("/instances/:iid/signoffs", teamWfSignoffH.Submit) diff --git a/server/middleware/team.go b/server/middleware/team.go index 14de5c6..fdab64c 100644 --- a/server/middleware/team.go +++ b/server/middleware/team.go @@ -5,20 +5,35 @@ import ( "github.com/gin-gonic/gin" + "armature/auth" "armature/store" ) +// isSystemAdmin checks if the user has the surface.admin.access permission, +// which grants system-wide admin privileges including team access bypass. +func isSystemAdmin(c *gin.Context, stores store.Stores) bool { + userID := c.GetString("user_id") + if userID == "" { + return false + } + perms, err := resolveAndCachePerms(c, stores, userID) + if err != nil { + return false + } + return perms[auth.PermSurfaceAdminAccess] +} + // RequireTeamAdmin returns middleware that restricts access to team admins. // System admins are always allowed through. -func RequireTeamAdmin(teams store.TeamStore) gin.HandlerFunc { +func RequireTeamAdmin(teams store.TeamStore, allStores ...store.Stores) gin.HandlerFunc { return func(c *gin.Context) { - role, _ := c.Get("role") - if role == "admin" { + // System admin bypass via permissions + if len(allStores) > 0 && isSystemAdmin(c, allStores[0]) { c.Next() return } - userID, _ := c.Get("user_id") + userID := c.GetString("user_id") teamID := c.Param("teamId") if teamID == "" { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ @@ -27,7 +42,7 @@ func RequireTeamAdmin(teams store.TeamStore) gin.HandlerFunc { return } - isAdmin, err := teams.IsTeamAdmin(c.Request.Context(), teamID, userID.(string)) + isAdmin, err := teams.IsTeamAdmin(c.Request.Context(), teamID, userID) if err != nil || !isAdmin { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "team admin access required", @@ -42,15 +57,15 @@ func RequireTeamAdmin(teams store.TeamStore) gin.HandlerFunc { // RequireTeamMember returns middleware that restricts access to users who // belong to the team identified by :teamId (any role). // System admins are always allowed through. -func RequireTeamMember(teams store.TeamStore) gin.HandlerFunc { +func RequireTeamMember(teams store.TeamStore, allStores ...store.Stores) gin.HandlerFunc { return func(c *gin.Context) { - role, _ := c.Get("role") - if role == "admin" { + // System admin bypass via permissions + if len(allStores) > 0 && isSystemAdmin(c, allStores[0]) { c.Next() return } - userID, _ := c.Get("user_id") + userID := c.GetString("user_id") teamID := c.Param("teamId") if teamID == "" { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ @@ -59,7 +74,7 @@ func RequireTeamMember(teams store.TeamStore) gin.HandlerFunc { return } - isMember, _ := teams.IsMember(c.Request.Context(), teamID, userID.(string)) + isMember, _ := teams.IsMember(c.Request.Context(), teamID, userID) if !isMember { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "team membership required", diff --git a/server/pages/pages.go b/server/pages/pages.go index 486e436..825da75 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -646,6 +646,7 @@ type WorkflowPageData struct { BrandingJSON string InstanceID string // workflow instance ID (used for API calls) Status string // pending | active | completed | cancelled | stale + AudienceMismatch bool // true when current stage audience is team/system but visitor is unauthenticated } // WorkflowLandingPageData is passed to workflow-landing.html. @@ -745,6 +746,22 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { entryToken = *inst.EntryToken } + // Detect audience mismatch: stage requires team/system but visitor is + // unauthenticated. Show a "submitted" screen instead of the stage form. + audienceMismatch := false + userID := c.GetString("user_id") + if userID == "" { + // Visitor is unauthenticated — check the current stage audience + for _, s := range stages { + if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage { + if s.Audience == models.AudienceTeam || s.Audience == models.AudienceSystem { + audienceMismatch = true + } + break + } + } + } + e.Render(c, "workflow.html", PageData{ Surface: "workflow", InstanceName: instanceName, @@ -763,6 +780,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { BrandingJSON: brandingJSON, InstanceID: inst.ID, Status: inst.Status, + AudienceMismatch: audienceMismatch, }, }) } diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 68a1767..090bcca 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -104,6 +104,26 @@ padding: 24px; text-align: center; color: var(--text-2); } + /* ── Submitted / handoff screen ── */ + .wf-submitted { + flex: 1; display: flex; align-items: center; justify-content: center; + padding: 40px 24px; text-align: center; + } + .wf-submitted-inner { max-width: 420px; } + .wf-submitted-icon { + width: 56px; height: 56px; border-radius: 50%; + background: var(--accent); color: #fff; + display: inline-flex; align-items: center; justify-content: center; + font-size: 28px; margin-bottom: 16px; + } + .wf-submitted h3 { font-size: 20px; font-weight: 600; margin-bottom: 8px; } + .wf-submitted p { color: var(--text-2); font-size: 14px; line-height: 1.5; } + .wf-submitted .ref-id { + margin-top: 16px; padding: 8px 16px; background: var(--bg-surface); + border: 1px solid var(--border); border-radius: 6px; + font-size: 12px; color: var(--text-3); display: inline-block; + } + /* ── Branding ────────────────────── */ .wf-branding-logo { max-height: 32px; margin-bottom: 4px; } .wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; } @@ -160,7 +180,18 @@ - {{if .Data.SurfacePkgID}} + {{if .Data.AudienceMismatch}} +
+
+ +

Submitted Successfully

+

Your submission has been received and is now being reviewed by our team. You can safely close this page.

+ {{if .Data.EntryToken}} +
Reference: {{.Data.EntryToken}}
+ {{end}} +
+
+ {{else if .Data.SurfacePkgID}}
{{else if eq .Data.StageMode "form"}}
diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 196cc92..e3c0820 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -517,6 +517,52 @@ func (s *WorkflowStore) ListInstances(ctx context.Context, workflowID string, st return result, rows.Err() } +func (s *WorkflowStore) ListInstancesByTeam(ctx context.Context, teamID string, status string, opts store.ListOptions) ([]models.WorkflowInstance, error) { + q := `SELECT i.id, i.workflow_id, i.workflow_version, i.current_stage, i.stage_data, + i.status, i.started_by, i.entry_token, i.metadata, + i.stage_entered_at, i.created_at, i.updated_at + FROM workflow_instances i + JOIN workflows w ON w.id = i.workflow_id + WHERE w.team_id = $1` + args := []interface{}{teamID} + idx := 2 + if status != "" { + q += fmt.Sprintf(" AND i.status = $%d", idx) + args = append(args, status) + idx++ + } + q += " ORDER BY i.created_at DESC" + if opts.Limit > 0 { + q += fmt.Sprintf(" LIMIT $%d", idx) + args = append(args, opts.Limit) + idx++ + } + if opts.Offset > 0 { + q += fmt.Sprintf(" OFFSET $%d", idx) + args = append(args, opts.Offset) + } + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []models.WorkflowInstance + for rows.Next() { + var inst models.WorkflowInstance + var stageData, metadata []byte + if err := rows.Scan(&inst.ID, &inst.WorkflowID, &inst.WorkflowVersion, + &inst.CurrentStage, &stageData, &inst.Status, &inst.StartedBy, + &inst.EntryToken, &metadata, + &inst.StageEnteredAt, &inst.CreatedAt, &inst.UpdatedAt); err != nil { + return nil, err + } + inst.StageData = stageData + inst.Metadata = metadata + result = append(result, inst) + } + return result, rows.Err() +} + func (s *WorkflowStore) AdvanceStage(ctx context.Context, id string, nextStage string, stageData json.RawMessage) error { sd := jsonOrEmpty(stageData) _, err := DB.ExecContext(ctx, ` diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index 405e5a7..238d9e8 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -534,6 +534,49 @@ func (s *WorkflowStore) ListInstances(ctx context.Context, workflowID string, st return result, rows.Err() } +func (s *WorkflowStore) ListInstancesByTeam(ctx context.Context, teamID string, status string, opts store.ListOptions) ([]models.WorkflowInstance, error) { + q := `SELECT i.id, i.workflow_id, i.workflow_version, i.current_stage, i.stage_data, + i.status, i.started_by, i.entry_token, i.metadata, + i.stage_entered_at, i.created_at, i.updated_at + FROM workflow_instances i + JOIN workflows w ON w.id = i.workflow_id + WHERE w.team_id = ?` + args := []interface{}{teamID} + if status != "" { + q += " AND i.status = ?" + args = append(args, status) + } + q += " ORDER BY i.created_at DESC" + if opts.Limit > 0 { + q += " LIMIT ?" + args = append(args, opts.Limit) + } + if opts.Offset > 0 { + q += " OFFSET ?" + args = append(args, opts.Offset) + } + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []models.WorkflowInstance + for rows.Next() { + var inst models.WorkflowInstance + var stageData, metadata string + if err := rows.Scan(&inst.ID, &inst.WorkflowID, &inst.WorkflowVersion, + &inst.CurrentStage, &stageData, &inst.Status, &inst.StartedBy, + &inst.EntryToken, &metadata, + st(&inst.StageEnteredAt), st(&inst.CreatedAt), st(&inst.UpdatedAt)); err != nil { + return nil, err + } + inst.StageData = json.RawMessage(stageData) + inst.Metadata = json.RawMessage(metadata) + result = append(result, inst) + } + return result, rows.Err() +} + func (s *WorkflowStore) AdvanceStage(ctx context.Context, id string, nextStage string, stageData json.RawMessage) error { sd := jsonOrEmpty(stageData) now := time.Now().UTC() diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go index ffa96a8..fbfb028 100644 --- a/server/store/workflow_iface.go +++ b/server/store/workflow_iface.go @@ -42,6 +42,7 @@ type WorkflowStore interface { CancelInstance(ctx context.Context, id string) error MarkInstanceStale(ctx context.Context, id string) error ListActiveInstances(ctx context.Context) ([]models.WorkflowInstance, error) + ListInstancesByTeam(ctx context.Context, teamID string, status string, opts ListOptions) ([]models.WorkflowInstance, error) // Assignments CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error diff --git a/server/workflow/engine.go b/server/workflow/engine.go index 4f9b95c..fa9d6ea 100644 --- a/server/workflow/engine.go +++ b/server/workflow/engine.go @@ -100,6 +100,8 @@ func (e *Engine) Start(ctx context.Context, workflowID string, initialData json. } if err := e.stores.Workflows.CreateAssignment(ctx, a); err != nil { log.Printf("[workflow-engine] create initial assignment: %v", err) + } else { + e.notifyAssignment(ctx, a, wf.Name, stages[0].Name) } } @@ -247,6 +249,12 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa "assignment_id": a.ID, "team_id": a.TeamID, }) + // Notify team members + wfTitle := "" + if wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID); wf != nil { + wfTitle = wf.Name + } + e.notifyAssignment(ctx, a, wfTitle, nextStage.Name) } } @@ -477,6 +485,45 @@ func (e *Engine) emit(_ context.Context, label, room string, payload map[string] }) } +// notifyAssignment creates notifications for team members when a workflow +// assignment is created. If the assignment targets a specific user, only +// that user is notified. Otherwise all team members are notified. +func (e *Engine) notifyAssignment(ctx context.Context, a *models.WorkflowAssignment, workflowTitle, stageName string) { + if e.stores.Notifications == nil || e.stores.Teams == nil { + return + } + + var recipients []string + if a.AssignedTo != nil && *a.AssignedTo != "" { + recipients = []string{*a.AssignedTo} + } else { + members, err := e.stores.Teams.ListMembers(ctx, a.TeamID) + if err != nil { + log.Printf("[workflow-engine] list team members for notification: %v", err) + return + } + for _, m := range members { + recipients = append(recipients, m.UserID) + } + } + + title := "Workflow assignment: " + stageName + body := workflowTitle + for _, uid := range recipients { + n := &models.Notification{ + UserID: uid, + Type: models.NotifTypeWorkflowAssign, + Title: title, + Body: body, + ResourceType: "workflow", + ResourceID: a.InstanceID, + } + if err := e.stores.Notifications.Create(ctx, n); err != nil { + log.Printf("[workflow-engine] create notification: %v", err) + } + } +} + // mergeJSON merges b into a (shallow). Returns a if b is empty. func mergeJSON(a, b json.RawMessage) json.RawMessage { if len(b) == 0 || string(b) == "{}" || string(b) == "null" { diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 9d0e1f5..29f7ccd 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -140,6 +140,11 @@ export function createDomains(restClient) { // Adopt global workflows availableWorkflows: (id) => rc.get(`/api/v1/teams/${id}/workflows/available`), adoptWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/adopt`, {}), + // Team assignments + assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)), + // Team workflow instances (cross-workflow) + workflowInstances: (id, opts) => rc.get(`/api/v1/teams/${id}/workflow-instances` + _qs(opts)), + cancelWorkflowInstance: (id, iid) => rc.post(`/api/v1/teams/${id}/workflow-instances/${iid}/cancel`, {}), }, // ── 9. Workflows ─────────────────────── @@ -154,7 +159,17 @@ export function createDomains(restClient) { instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)), advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data), reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data), - cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}), + cancel: (wfId, instanceId) => rc.post(`/api/v1/workflows/${wfId}/instances/${instanceId}/cancel`, {}), + }, + + // ── 15. Workflow Assignments ───────── + workflowAssignments: { + mine: (opts) => rc.get('/api/v1/assignments/mine' + _qs(opts)), + claim: (id) => rc.post(`/api/v1/assignments/${id}/claim`, {}), + unclaim: (id) => rc.post(`/api/v1/assignments/${id}/unclaim`, {}), + complete: (id, data) => rc.post(`/api/v1/assignments/${id}/complete`, data || {}), + cancel: (id) => rc.post(`/api/v1/assignments/${id}/cancel`, {}), + assign: (id, userId) => rc.post(`/api/v1/assignments/${id}/assign`, { user_id: userId }), }, // ── 10. Surfaces ────────────────────── diff --git a/src/js/sw/surfaces/team-admin/workflow-monitor.js b/src/js/sw/surfaces/team-admin/workflow-monitor.js index 0cda541..6248dd2 100644 --- a/src/js/sw/surfaces/team-admin/workflow-monitor.js +++ b/src/js/sw/surfaces/team-admin/workflow-monitor.js @@ -33,7 +33,9 @@ function _formatDuration(seconds) { export function AssignmentsTab({ teamId }) { const [claimed, setClaimed] = useState([]); const [unassigned, setUnassigned] = useState([]); + const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); + const [assigningId, setAssigningId] = useState(null); const load = useCallback(async () => { try { @@ -47,7 +49,15 @@ export function AssignmentsTab({ teamId }) { finally { setLoading(false); } }, [teamId]); - useEffect(() => { load(); }, [load]); + // Load team members for the assign dropdown + const loadMembers = useCallback(async () => { + try { + const data = await sw.api.teams.members(teamId); + setMembers(data || []); + } catch { setMembers([]); } + }, [teamId]); + + useEffect(() => { load(); loadMembers(); }, [load, loadMembers]); // WS live updates useEffect(() => { @@ -79,9 +89,16 @@ export function AssignmentsTab({ teamId }) { } catch (e) { sw.toast(e.message, 'error'); } } - if (loading) return html`
Loading\u2026
`; + async function assignTo(assignmentId, userId) { + try { + await sw.api.workflowAssignments.assign(assignmentId, userId); + sw.toast('Assigned', 'success'); + setAssigningId(null); + load(); + } catch (e) { sw.toast(e.message, 'error'); } + } - const userId = sw.auth?.user?.id; + if (loading) return html`
Loading\u2026
`; return html`
@@ -97,7 +114,7 @@ export function AssignmentsTab({ teamId }) {
+ onClick=${() => { location.href = sw.base + '/w/' + a.instance_id; }}>Work
@@ -114,7 +131,28 @@ export function AssignmentsTab({ teamId }) { ${a.stage_name || `Stage ${a.stage}`} ${_timeAgo(a.created_at)} - +
+ + +
+ ${assigningId === a.id && html` +
+ + +
+ `} `)} ${unassigned.length === 0 && html`
No available assignments
`} @@ -157,7 +195,7 @@ export function MonitorTab({ teamId }) {

Active Instances (${instances.length})

${instances.map(inst => html` -
+
${inst.workflow_name} ${inst.stage_name || `Stage ${inst.current_stage}`} @@ -168,8 +206,8 @@ export function MonitorTab({ teamId }) { - + onClick=${() => { location.href = sw.base + '/w/' + inst.id; }}>Open +
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}