Feat v0.7.10 workflow handoff (#64)
All checks were successful
CI/CD / test-go-pg (push) Successful in 2m54s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m47s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
All checks were successful
CI/CD / test-go-pg (push) Successful in 2m54s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m47s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #64.
This commit is contained in:
40
CHANGELOG.md
40
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
|
||||
|
||||
16
ROADMAP.md
16
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)
|
||||
|
||||
|
||||
232
ci/e2e-workflow-handoff.sh
Executable file
232
ci/e2e-workflow-handoff.sh
Executable file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
||||
|
||||
{{if .Data.SurfacePkgID}}
|
||||
{{if .Data.AudienceMismatch}}
|
||||
<div class="wf-submitted">
|
||||
<div class="wf-submitted-inner">
|
||||
<div class="wf-submitted-icon">✓</div>
|
||||
<h3>Submitted Successfully</h3>
|
||||
<p>Your submission has been received and is now being reviewed by our team. You can safely close this page.</p>
|
||||
{{if .Data.EntryToken}}
|
||||
<div class="ref-id">Reference: {{.Data.EntryToken}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else if .Data.SurfacePkgID}}
|
||||
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||
{{else if eq .Data.StageMode "form"}}
|
||||
<div class="wf-form" id="formArea"></div>
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
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`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
@@ -97,7 +114,7 @@ export function AssignmentsTab({ teamId }) {
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.instance_id; }}>Work</button>
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => complete(a.id)}>Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +131,28 @@ export function AssignmentsTab({ teamId }) {
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
|
||||
</div>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setAssigningId(assigningId === a.id ? null : a.id)}>Assign</button>
|
||||
</div>
|
||||
${assigningId === a.id && html`
|
||||
<div style="margin-top:6px;display:flex;gap:4px;align-items:center;font-size:12px;">
|
||||
<select id=${'assign_' + a.id} style="flex:1;font-size:12px;">
|
||||
<option value="">Select member\u2026</option>
|
||||
${members.map(m => html`
|
||||
<option key=${m.user_id} value=${m.user_id}>
|
||||
${sw.users?.displayName?.(m.user_id) || m.display_name || m.username || m.user_id}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => {
|
||||
const sel = document.getElementById('assign_' + a.id);
|
||||
if (sel?.value) assignTo(a.id, sel.value);
|
||||
}}>Confirm</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
|
||||
@@ -157,7 +195,7 @@ export function MonitorTab({ teamId }) {
|
||||
<h4 style="margin:0 0 8px;">Active Instances (${instances.length})</h4>
|
||||
<div class="admin-list">
|
||||
${instances.map(inst => html`
|
||||
<div class="admin-surface-row" key=${inst.channel_id}>
|
||||
<div class="admin-surface-row" key=${inst.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${inst.workflow_name}</strong>
|
||||
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
|
||||
@@ -168,8 +206,8 @@ export function MonitorTab({ teamId }) {
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user