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}} +
Your submission has been received and is now being reviewed by our team. You can safely close this page.
+ {{if .Data.EntryToken}} +