From 0773c86c2776864b45095021e65ebac6784a70af Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sat, 28 Mar 2026 01:15:33 +0000 Subject: [PATCH] Feat v0.3.4 team roles signoff (#18) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 86 +++++++++++ ROADMAP.md | 10 +- VERSION | 2 +- docs/DESIGN-EXTENSION-LIFECYCLE.md | 49 ++++++ docs/DESIGN-TRIGGER-COMPOSITION.md | 57 +++++++ .../migrations/postgres/002_teams.sql | 3 +- .../migrations/postgres/007_workflows.sql | 16 ++ .../database/migrations/sqlite/002_teams.sql | 2 +- .../migrations/sqlite/007_workflows.sql | 16 ++ server/database/testhelper.go | 3 +- server/events/types.go | 2 + server/handlers/teams.go | 117 +++++++++++++- .../handlers/workflow_assignment_handlers.go | 18 ++- server/handlers/workflow_signoff_handlers.go | 72 +++++++++ server/handlers/workflow_store_test.go | 132 ++++++++++++++++ server/main.go | 12 ++ server/models/workflow.go | 19 +++ server/store/postgres/workflows.go | 42 +++++ server/store/sqlite/workflows.go | 44 ++++++ server/store/workflow_iface.go | 5 + server/workflow/engine.go | 144 ++++++++++++++++++ server/workflow/routing.go | 24 ++- src/js/sw/surfaces/team-admin/members.js | 65 +++++++- src/js/sw/surfaces/team-admin/workflows.js | 119 +++++++++++++++ 24 files changed, 1039 insertions(+), 20 deletions(-) create mode 100644 docs/DESIGN-EXTENSION-LIFECYCLE.md create mode 100644 docs/DESIGN-TRIGGER-COMPOSITION.md create mode 100644 server/handlers/workflow_signoff_handlers.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c215a41..d1fec80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ All notable changes to Switchboard Core are documented here. +## v0.3.4 — Team Roles + Multi-party Validation + +### Added + +- **Custom team roles**: Removed `CHECK (role IN ('admin','member'))` constraint + from `team_members` (both Postgres and SQLite). Custom roles are stored in + `teams.settings["roles"]` as a JSON array. Builtins `admin` and `member` are + always present. +- **Team roles API**: `GET /api/v1/teams/:teamId/roles` returns configured roles. + `PUT /api/v1/teams/:teamId/roles` replaces the roles array (builtins enforced). +- **Role-based stage assignment**: `stage_config.required_role` restricts which + team members can claim an assignment. `CheckClaimRole` verifies the user's team + membership role before allowing claim; rolls back on mismatch (403). +- **Multi-party sign-off**: New `workflow_signoffs` table with + `UNIQUE(instance_id, stage, user_id)` preventing double-signing. + `StageConfig.validation` supports `required_approvals`, `required_role`, and + `reject_action` (cancel or reroute to named stage). +- **Validation gate**: `advanceInternal` blocks stage advancement until the + required number of approvals is met. Rejections trigger cancel or reroute + based on `reject_action`. +- **`Engine.SubmitSignoff`**: Records approval/rejection, enforces signoff role + if configured, emits `workflow.signoff` event. +- **Signoff HTTP API**: `POST /api/v1/instances/:iid/signoffs` (submit), + `GET /api/v1/instances/:iid/signoffs` (list current stage signoffs). + Team-scoped mirrors at `/api/v1/teams/:teamId/instances/:iid/signoffs`. +- **New events**: `workflow.signoff`, `workflow.rejected`. +- **Frontend — Members page**: Dynamic role selects populated from team roles API. + "Manage Roles" panel for adding/removing custom roles inline. +- **Frontend — Stage editor**: "Required Role (claim)" dropdown and "Multi-party + Validation" section (required approvals, signoff role, on reject action) appear + when a team is selected for the stage. +- **Frontend — Monitor tab**: Signoff panel with approve/reject buttons, comment + field, and approval progress display. +- **3 new store tests**: CreateSignoff (with UNIQUE constraint check), + ListSignoffs (ordering), CountSignoffs (decision filter). 20 total, all passing. +- **Design doc**: `docs/DESIGN-EXTENSION-LIFECYCLE.md` — permanent vs PoC + packages, graduation criteria, explicit install model. +- **Design doc**: `docs/DESIGN-TRIGGER-COMPOSITION.md` — triggers/schedules can + start workflows, workflows emit events, no circular invocation. + +### Changed + +- `addMemberRequest` and `updateMemberRequest` binding relaxed from + `oneof=admin member` to `required,min=1,max=50` with application-level + validation against the team's configured roles. +- `TruncateAll` test helper updated to include `workflow_signoffs`. + +--- + ## v0.3.3 — Public Entry + Background Jobs ### Added @@ -23,6 +72,43 @@ All notable changes to Switchboard Core are documented here. --- +## v0.3.2 — Workflow Engine + +### Added + +- **Stage execution engine**: `server/workflow/engine.go` — `Start`, `Advance` + (with `branch_rules` conditional routing), and `Cancel` operations. Merges + `stage_data` across stages, creates assignments, emits lifecycle events. +- **Automated stage handler**: `server/workflow/automated.go` — fires Starlark + hook, auto-advances on success, cycle guard (max 10 consecutive automated + stages). +- **Instance HTTP API**: Start, GetInstance, Advance, Cancel, ListInstances. + Team-scoped mirrors under `/api/v1/teams/:teamId/workflows/`. +- **Assignment HTTP API**: Claim, Unclaim, Complete, Cancel, ListByTeam, + ListMine. Claimer identity verification on unclaim/complete. +- **Starlark module expansion**: `workflow.get_instance()`, + `workflow.list_instances()` (read-only). +- **14 store tests** covering all 15 v0.3.1 methods (instance + assignment + CRUD). All passing. + +--- + +## v0.3.1 — Instance Assignment + +### Added + +- **`workflow_instances` table**: Tracks workflow execution state — + `workflow_version`, `current_stage`, `stage_data`, `status`, `entry_token`. +- **`workflow_assignments` table**: Per-stage queue for instance assignments — + `instance_id`, `stage`, `team_id`, `assigned_to`, `status`, `review_data`. + Optimistic claim locking via status transition. +- **15 new store methods**: Full CRUD for instances (Create, Get, GetByToken, + Update, List, AdvanceStage, Complete, Cancel) and assignments (Create, Claim, + Unclaim, Complete, Cancel, ListByTeam, ListByInstance, ListByUser). +- **New events**: `workflow.started`, `workflow.cancelled`, `workflow.error`. + +--- + ## v0.3.0 — Workflow Schema Redesign ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 031294f..c4b72f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,14 +160,14 @@ finalizes the extension lifecycle model. | SLA scanner | ✅ | Background goroutine (5-min interval) checking active instances against per-stage `sla_seconds`. Fires `workflow.sla_breach` event, marks breach in instance metadata (idempotent). | | Staleness sweep | ✅ | Per-workflow `staleness_timeout_hours` column. Scanner marks idle instances as `stale`, cancels open assignments, fires `workflow.stale` event. | -### v0.3.4 — Team Roles + Multi-party Validation +### v0.3.4 — Team Roles + Multi-party Validation (complete) | Step | Status | Description | |------|--------|-------------| -| Team roles | ⬚ | Expand `team_members.role` beyond admin/member. Role-based stage assignment rules. | -| Multi-party sign-off | ⬚ | 2-party validation at stage boundaries. | -| Extension lifecycle | ⬚ | Define permanent vs PoC packages. Graduation criteria. | -| Trigger composition | ⬚ | How triggers, schedules, and workflows compose. Design doc. | +| Team roles | ✅ | Removed CHECK constraint on `team_members.role`. Custom roles stored in `teams.settings["roles"]`. Team roles API (`GET/PUT /teams/:teamId/roles`). Role-based stage assignment via `stage_config.required_role`. Frontend: dynamic role selects, role management panel. | +| Multi-party sign-off | ✅ | `workflow_signoffs` table (both dialects). `StageConfig.validation` with `required_approvals`, `required_role`, `reject_action`. Engine validation gate in `advanceInternal`. `SubmitSignoff` engine method. Signoff HTTP API (`POST/GET /instances/:iid/signoffs`). Frontend signoff panel in monitor tab. | +| Extension lifecycle | ✅ | Design doc: `docs/DESIGN-EXTENSION-LIFECYCLE.md`. Permanent vs PoC classification, graduation criteria, install model. | +| Trigger composition | ✅ | Design doc: `docs/DESIGN-TRIGGER-COMPOSITION.md`. Triggers/schedules can start workflows, workflows emit events, no circular invocation. | ### v0.3.5 — Settings Audit + ICD + Tests diff --git a/VERSION b/VERSION index 0d91a54..42045ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.3.4 diff --git a/docs/DESIGN-EXTENSION-LIFECYCLE.md b/docs/DESIGN-EXTENSION-LIFECYCLE.md new file mode 100644 index 0000000..24dae63 --- /dev/null +++ b/docs/DESIGN-EXTENSION-LIFECYCLE.md @@ -0,0 +1,49 @@ +# Extension Lifecycle: Permanent vs PoC + +## Overview + +Packages (extensions) fall into two categories: **permanent** and **PoC** (proof-of-concept). +This classification drives install-by-default behavior and maintenance expectations. + +## Permanent Packages + +Permanent packages are part of the platform kernel or essential surfaces. They are always +available and maintained by core contributors. + +| Package | Reason | +|------------------|--------------------------------| +| admin | System administration surface | +| team-admin | Team management surface | +| settings | User settings surface | +| login | Authentication surface | +| welcome | Onboarding / empty-state | + +## PoC Packages + +Everything else in `packages/` is PoC. These demonstrate the extension model but are not +required for platform operation. Examples: tasks, schedules, dashboard, editor, git-board, +icd-test-runner, sdk-test-runner. + +Retired builtins (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, +regex-tester) are PoC packages migrated from the kernel during the v0.2.9 retirement. + +## Graduation Criteria + +A PoC package may be promoted to permanent when all of the following are met: + +1. **Active usage** — 3+ active users or teams depend on it. +2. **Test coverage** — At least basic smoke tests exist (Starlark or integration). +3. **No kernel special-casing** — The package operates entirely through the public extension + API. No `if pkg == "X"` branches in core. +4. **Documentation** — README with install instructions, config options, and screenshots. +5. **Maintenance owner** — A named contributor commits to reviewing issues. + +## Deprecation + +Packages that no longer meet graduation criteria (e.g., zero usage for 6 months) may be +archived. Archived packages remain installable but receive no updates. + +## Install Model + +All packages use explicit installation: `POST /api/v1/packages/:id/install`. There is no +auto-install. The kernel ships clean; teams choose what they need. diff --git a/docs/DESIGN-TRIGGER-COMPOSITION.md b/docs/DESIGN-TRIGGER-COMPOSITION.md new file mode 100644 index 0000000..7478245 --- /dev/null +++ b/docs/DESIGN-TRIGGER-COMPOSITION.md @@ -0,0 +1,57 @@ +# Trigger Composition: Triggers, Schedules, and Workflows + +## Overview + +Three automation primitives exist in the platform: **triggers** (event-driven), **schedules** +(time-driven), and **workflows** (multi-step state machines). This document defines how they +compose without creating circular dependencies. + +## Composition Rules + +### Triggers can start workflows + +A trigger's Starlark handler may call `workflow.start(workflow_id, data)` to create a new +workflow instance. This is the primary entry point for event-driven workflow initiation. + +Example: a `ticket.created` trigger fires and starts an approval workflow. + +### Schedules can start workflows + +A schedule's Starlark handler may also call `workflow.start(...)`. This enables time-based +batch processing workflows. + +Example: a daily 9am schedule starts a standup collection workflow. + +### Workflows emit events that triggers listen to + +Workflow lifecycle events (`workflow.started`, `workflow.advanced`, `workflow.completed`, +`workflow.signoff`, etc.) are published on the event bus. Triggers may subscribe to these +events to perform side effects (notifications, external API calls, audit logging). + +### Workflows cannot start triggers or schedules + +Workflows do not directly invoke triggers or create schedules. Workflow Starlark hooks +operate within the workflow's own context and do not have access to trigger/schedule +management APIs. This prevents uncontrolled fan-out. + +## Circular Invocation Guard + +The automated stage cycle guard (max 10 consecutive automated stages) already prevents +infinite loops within a single workflow. Cross-workflow cycles are prevented by this rule: + +**A workflow event handler (trigger) may start a new workflow instance, but the new instance +is not processed synchronously.** It enters the work queue as a separate execution context. +The bus does not re-enter the originating workflow's advance call. + +This means: +- `workflow.completed` trigger → `workflow.start(B)` is allowed (async). +- Workflow A automated stage → `workflow.start(B)` where B immediately completes and + triggers A again → allowed but rate-limited by the cycle guard on each individual workflow. + +## Not Supported (by design) + +- Triggers cannot modify a running workflow instance (read-only access via Starlark builtins). +- Schedules cannot advance or cancel workflow instances directly. +- Workflows cannot create, modify, or delete triggers or schedules. + +These restrictions keep the composition model simple and auditable. diff --git a/server/database/migrations/postgres/002_teams.sql b/server/database/migrations/postgres/002_teams.sql index 7e5ef1d..5c95adf 100644 --- a/server/database/migrations/postgres/002_teams.sql +++ b/server/database/migrations/postgres/002_teams.sql @@ -22,8 +22,7 @@ CREATE TABLE IF NOT EXISTS team_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL DEFAULT 'member' - CHECK (role IN ('admin', 'member')), + role VARCHAR(50) NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(team_id, user_id) ); diff --git a/server/database/migrations/postgres/007_workflows.sql b/server/database/migrations/postgres/007_workflows.sql index 8bd4292..fabbe83 100644 --- a/server/database/migrations/postgres/007_workflows.sql +++ b/server/database/migrations/postgres/007_workflows.sql @@ -131,3 +131,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status ON workflow_assignments(team_id, status); CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL; + +-- ── Workflow Signoffs (v0.3.4) ──────────────── + +CREATE TABLE IF NOT EXISTS workflow_signoffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + user_id TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')), + comment TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(instance_id, stage, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage + ON workflow_signoffs(instance_id, stage); diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index 76fba74..2490e03 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS team_members ( id TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), + role TEXT NOT NULL DEFAULT 'member', joined_at TEXT DEFAULT (datetime('now')), UNIQUE(team_id, user_id) ); diff --git a/server/database/migrations/sqlite/007_workflows.sql b/server/database/migrations/sqlite/007_workflows.sql index b6af4cc..772295e 100644 --- a/server/database/migrations/sqlite/007_workflows.sql +++ b/server/database/migrations/sqlite/007_workflows.sql @@ -120,3 +120,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status ON workflow_assignments(team_id, status); CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned ON workflow_assignments(assigned_to); + +-- ── Workflow Signoffs (v0.3.4) ──────────────── + +CREATE TABLE IF NOT EXISTS workflow_signoffs ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + user_id TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')), + comment TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(instance_id, stage, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage + ON workflow_signoffs(instance_id, stage); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 2fc7643..495b530 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -256,7 +256,8 @@ func TruncateAll(t *testing.T) { // HA "rate_limit_counters", "ws_tickets", - // Workflows (FK order: assignments → instances → versions → stages → workflows) + // Workflows (FK order: signoffs → assignments → instances → versions → stages → workflows) + "workflow_signoffs", "workflow_assignments", "workflow_instances", "workflow_versions", diff --git a/server/events/types.go b/server/events/types.go index 27f1cb1..ecdaf8e 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -67,6 +67,8 @@ var routeTable = map[string]Direction{ "workflow.error": DirToClient, // engine/hook error "workflow.sla_breach": DirToClient, // SLA exceeded → team + admins (v0.3.3) "workflow.stale": DirToClient, // instance marked stale (v0.3.3) + "workflow.signoff": DirToClient, // signoff submitted (v0.3.4) + "workflow.rejected": DirToClient, // rejection triggered cancel/reroute (v0.3.4) // Plugin hooks — never cross the wire "plugin.hook.": DirLocal, diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 6ec0b6d..b8d3dbe 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -1,7 +1,10 @@ package handlers import ( + "context" "database/sql" + "encoding/json" + "fmt" "net/http" "strconv" @@ -29,11 +32,11 @@ type updateTeamRequest struct { type addMemberRequest struct { UserID string `json:"user_id" binding:"required"` - Role string `json:"role" binding:"required,oneof=admin member"` + Role string `json:"role" binding:"required,min=1,max=50"` } type updateMemberRequest struct { - Role string `json:"role" binding:"required,oneof=admin member"` + Role string `json:"role" binding:"required,min=1,max=50"` } // ── Handler ───────────────────────────────── @@ -258,6 +261,12 @@ func (h *TeamHandler) AddMember(c *gin.Context) { return } + // Validate role against team's configured roles + if err := validateTeamRole(ctx, h.stores, teamID, req.Role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role) if err != nil { if database.IsUniqueViolation(err) { @@ -286,6 +295,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) { return } + // Validate role against team's configured roles + if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) @@ -403,3 +418,101 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"actions": actions}) } + +// ── Team Roles API (v0.3.4) ───────────────── + +// builtinRoles are always present in every team's role list. +var builtinRoles = []string{"admin", "member"} + +// ListRoles returns the team's configured roles (builtins + custom). +// GET /api/v1/teams/:teamId/roles +func (h *TeamHandler) ListRoles(c *gin.Context) { + teamID := getTeamID(c) + roles := getTeamRoles(c.Request.Context(), h.stores, teamID) + c.JSON(http.StatusOK, gin.H{"data": roles}) +} + +// UpdateRoles replaces the team's roles array. Builtins (admin, member) are always included. +// PUT /api/v1/teams/:teamId/roles +func (h *TeamHandler) UpdateRoles(c *gin.Context) { + teamID := getTeamID(c) + + var body struct { + Roles []string `json:"roles" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Ensure builtins are present + roleSet := map[string]bool{} + for _, r := range builtinRoles { + roleSet[r] = true + } + for _, r := range body.Roles { + if r != "" && len(r) <= 50 { + roleSet[r] = true + } + } + roles := make([]string, 0, len(roleSet)) + for r := range roleSet { + roles = append(roles, r) + } + + rolesJSON, _ := json.Marshal(map[string]any{"roles": roles}) + if err := h.stores.Teams.MergeSettings(c.Request.Context(), teamID, string(rolesJSON)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update roles"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": roles}) + AuditLog(h.stores.Audit, c, "team.update_roles", "team", teamID, map[string]interface{}{"roles": roles}) +} + +// getTeamRoles reads the roles array from team settings, falling back to builtins. +func getTeamRoles(ctx context.Context, stores store.Stores, teamID string) []string { + team, err := stores.Teams.GetByID(ctx, teamID) + if err != nil || team.Settings == nil { + return builtinRoles + } + + rolesRaw, ok := team.Settings["roles"] + if !ok { + return builtinRoles + } + + // rolesRaw is []interface{} from JSONMap + arr, ok := rolesRaw.([]interface{}) + if !ok { + return builtinRoles + } + + roles := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + roles = append(roles, s) + } + } + if len(roles) == 0 { + return builtinRoles + } + return roles +} + +// validateTeamRole checks that a role string is valid for the given team. +func validateTeamRole(ctx context.Context, stores store.Stores, teamID, role string) error { + roles := getTeamRoles(ctx, stores, teamID) + for _, r := range roles { + if r == role { + return nil + } + } + // Also allow builtins unconditionally (in case settings are empty) + for _, r := range builtinRoles { + if r == role { + return nil + } + } + return fmt.Errorf("role %q is not configured for this team", role) +} diff --git a/server/handlers/workflow_assignment_handlers.go b/server/handlers/workflow_assignment_handlers.go index eee7eba..70d9470 100644 --- a/server/handlers/workflow_assignment_handlers.go +++ b/server/handlers/workflow_assignment_handlers.go @@ -30,12 +30,28 @@ func NewWorkflowAssignmentHandler(engine *workflow.Engine, stores store.Stores) func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { userID := c.GetString("user_id") assignmentID := c.Param("id") + ctx := c.Request.Context() - if err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID); err != nil { + // Claim first, then verify role. Rollback if role check fails. + if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, userID); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } + // Role check: find the assignment in user's claimed list and verify role + claimed, _ := h.stores.Workflows.ListAssignmentsByUser(ctx, userID, models.AssignmentStatusClaimed) + for _, a := range claimed { + if a.ID == assignmentID { + if rerr := workflow.CheckClaimRole(ctx, h.stores, &a, userID); rerr != nil { + // Rollback: unclaim + h.stores.Workflows.UnclaimAssignment(ctx, assignmentID) + c.JSON(http.StatusForbidden, gin.H{"error": rerr.Error()}) + return + } + break + } + } + c.JSON(http.StatusOK, gin.H{"claimed": true}) } diff --git a/server/handlers/workflow_signoff_handlers.go b/server/handlers/workflow_signoff_handlers.go new file mode 100644 index 0000000..fcbb22c --- /dev/null +++ b/server/handlers/workflow_signoff_handlers.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "switchboard-core/models" + "switchboard-core/store" + "switchboard-core/workflow" +) + +// ── Signoff Handlers (v0.3.4) ───────────────── + +// WorkflowSignoffHandler manages signoff HTTP endpoints. +type WorkflowSignoffHandler struct { + engine *workflow.Engine + stores store.Stores +} + +// NewWorkflowSignoffHandler creates a signoff handler. +func NewWorkflowSignoffHandler(engine *workflow.Engine, stores store.Stores) *WorkflowSignoffHandler { + return &WorkflowSignoffHandler{engine: engine, stores: stores} +} + +// Submit records a signoff (approve/reject) for the current stage of an instance. +// POST /api/v1/instances/:iid/signoffs +func (h *WorkflowSignoffHandler) Submit(c *gin.Context) { + userID := c.GetString("user_id") + instanceID := c.Param("iid") + + var body struct { + Decision string `json:"decision" binding:"required,oneof=approve reject"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + so, err := h.engine.SubmitSignoff(c.Request.Context(), instanceID, userID, body.Decision, body.Comment) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, so) +} + +// List returns signoffs for the current stage of an instance. +// GET /api/v1/instances/:iid/signoffs +func (h *WorkflowSignoffHandler) List(c *gin.Context) { + instanceID := c.Param("iid") + + // Get the instance to determine current stage + inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), instanceID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"}) + return + } + + signoffs, err := h.stores.Workflows.ListSignoffs(c.Request.Context(), instanceID, inst.CurrentStage) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if signoffs == nil { + signoffs = []models.WorkflowSignoff{} + } + + c.JSON(http.StatusOK, gin.H{"data": signoffs}) +} diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index a378418..3aa9e14 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -762,3 +762,135 @@ func TestWorkflowAssignment_ListByTeam(t *testing.T) { t.Errorf("teamB count = %d, want 1", len(listB)) } } + +// ── Signoff store tests (v0.3.4) ────────────── + +func TestWorkflowSignoff_CreateAndList(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Signer", "signer@test.com") + teamID := database.SeedTestTeam(t, "Signoff Team", userID) + wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID) + + inst := &models.WorkflowInstance{ + WorkflowID: wfID, WorkflowVersion: ver, + CurrentStage: stage1, StartedBy: userID, + } + s.Workflows.CreateInstance(ctx, inst) + + // Create two signoffs + so1 := &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, + UserID: userID, Decision: models.SignoffApprove, Comment: "LGTM", + } + if err := s.Workflows.CreateSignoff(ctx, so1); err != nil { + t.Fatalf("create signoff: %v", err) + } + if so1.ID == "" { + t.Fatal("signoff ID not populated") + } + if so1.CreatedAt.IsZero() { + t.Fatal("signoff created_at not populated") + } + + user2 := database.SeedTestUser(t, "Signer2", "signer2@test.com") + so2 := &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, + UserID: user2, Decision: models.SignoffReject, Comment: "Needs work", + } + if err := s.Workflows.CreateSignoff(ctx, so2); err != nil { + t.Fatalf("create signoff 2: %v", err) + } + + // List should return both, ordered by created_at + list, err := s.Workflows.ListSignoffs(ctx, inst.ID, stage1) + if err != nil { + t.Fatalf("list signoffs: %v", err) + } + if len(list) != 2 { + t.Fatalf("signoff count = %d, want 2", len(list)) + } + if list[0].Decision != models.SignoffApprove { + t.Errorf("first signoff decision = %s, want approve", list[0].Decision) + } + if list[1].Decision != models.SignoffReject { + t.Errorf("second signoff decision = %s, want reject", list[1].Decision) + } + + // UNIQUE constraint: same user + instance + stage should fail + dup := &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, + UserID: userID, Decision: models.SignoffApprove, + } + if err := s.Workflows.CreateSignoff(ctx, dup); err == nil { + t.Fatal("expected UNIQUE violation for duplicate signoff, got nil") + } +} + +func TestWorkflowSignoff_ListEmpty(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + ctx := context.Background() + + // ListSignoffs on non-existent instance should return empty, not error + list, err := s.Workflows.ListSignoffs(ctx, "nonexistent", "stage") + if err != nil { + t.Fatalf("list signoffs error: %v", err) + } + if list != nil && len(list) != 0 { + t.Fatalf("expected empty list, got %d", len(list)) + } +} + +func TestWorkflowSignoff_Count(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + s := testStores(t) + ctx := context.Background() + + userID := database.SeedTestUser(t, "Counter", "counter@test.com") + teamID := database.SeedTestTeam(t, "Count Team", userID) + wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID) + + inst := &models.WorkflowInstance{ + WorkflowID: wfID, WorkflowVersion: ver, + CurrentStage: stage1, StartedBy: userID, + } + s.Workflows.CreateInstance(ctx, inst) + + // No signoffs yet + count, err := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove) + if err != nil { + t.Fatalf("count signoffs error: %v", err) + } + if count != 0 { + t.Fatalf("expected 0, got %d", count) + } + + // Add 2 approvals and 1 rejection + user2 := database.SeedTestUser(t, "Counter2", "counter2@test.com") + user3 := database.SeedTestUser(t, "Counter3", "counter3@test.com") + + s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, UserID: userID, Decision: models.SignoffApprove, + }) + s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, UserID: user2, Decision: models.SignoffApprove, + }) + s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{ + InstanceID: inst.ID, Stage: stage1, UserID: user3, Decision: models.SignoffReject, + }) + + approveCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove) + if approveCount != 2 { + t.Errorf("approve count = %d, want 2", approveCount) + } + rejectCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffReject) + if rejectCount != 1 { + t.Errorf("reject count = %d, want 1", rejectCount) + } +} diff --git a/server/main.go b/server/main.go index a6625f5..4f1a326 100644 --- a/server/main.go +++ b/server/main.go @@ -433,6 +433,11 @@ func main() { protected.POST("/assignments/:id/cancel", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfAssignH.Cancel) protected.GET("/assignments/mine", wfAssignH.ListMine) + // Workflow signoffs (v0.3.4) + wfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores) + protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit) + protected.GET("/instances/:iid/signoffs", wfSignoffH.List) + // Surface discovery (v0.25.0, v0.28.7: unified packages) pkgH := handlers.NewPackageHandler(stores) protected.GET("/surfaces", pkgH.ListEnabledSurfaces) @@ -515,6 +520,8 @@ func main() { teamScoped.POST("/members", teams.AddMember) teamScoped.PUT("/members/:memberId", teams.UpdateMember) teamScoped.DELETE("/members/:memberId", teams.RemoveMember) + teamScoped.GET("/roles", teams.ListRoles) + teamScoped.PUT("/roles", teams.UpdateRoles) // Team groups (team admins manage team-scoped groups) teamScoped.GET("/groups", groupH.ListTeamGroups) @@ -566,6 +573,11 @@ func main() { teamWfAssignH := handlers.NewWorkflowAssignmentHandler(wfEngine, stores) teamScoped.GET("/assignments", teamWfAssignH.ListByTeam) + // Team workflow signoffs (v0.3.4) + teamWfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores) + teamScoped.POST("/instances/:iid/signoffs", teamWfSignoffH.Submit) + teamScoped.GET("/instances/:iid/signoffs", teamWfSignoffH.List) + } // Public global settings (non-admin users can read safe subset) diff --git a/server/models/workflow.go b/server/models/workflow.go index 15fb103..3149474 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -504,6 +504,25 @@ type WorkflowAssignment struct { CreatedAt time.Time `json:"created_at"` } +// ── Workflow Signoff (v0.3.4) ─────────────── + +// WorkflowSignoff records a user's approval or rejection at a stage boundary. +type WorkflowSignoff struct { + ID string `json:"id"` + InstanceID string `json:"instance_id"` + Stage string `json:"stage"` + UserID string `json:"user_id"` + Decision string `json:"decision"` // approve | reject + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` +} + +// Signoff decision constants. +const ( + SignoffApprove = "approve" + SignoffReject = "reject" +) + // ── Workflow Version (immutable snapshot) ─── // WorkflowVersion is an immutable snapshot of a workflow definition diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 6a2aef2..4afd8f3 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -660,6 +660,48 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string return s.queryAssignments(ctx, q, args...) } +// ── Signoffs (v0.3.4) ───────────────────────── + +func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO workflow_signoffs (instance_id, stage, user_id, decision, comment) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_at`, + so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment, + ).Scan(&so.ID, &so.CreatedAt) +} + +func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, instance_id, stage, user_id, decision, comment, created_at + FROM workflow_signoffs + WHERE instance_id = $1 AND stage = $2 + ORDER BY created_at`, instanceID, stage) + if err != nil { + return nil, err + } + defer rows.Close() + var result []models.WorkflowSignoff + for rows.Next() { + var so models.WorkflowSignoff + if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID, + &so.Decision, &so.Comment, &so.CreatedAt); err != nil { + return nil, err + } + result = append(result, so) + } + return result, rows.Err() +} + +func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM workflow_signoffs + WHERE instance_id = $1 AND stage = $2 AND decision = $3`, + instanceID, stage, decision).Scan(&count) + return count, err +} + func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index 2a2bc9e..17b45c4 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -686,6 +686,50 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string return s.queryAssignments(ctx, q, args...) } +// ── Signoffs (v0.3.4) ───────────────────────── + +func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error { + so.ID = store.NewID() + so.CreatedAt = time.Now().UTC() + _, err := DB.ExecContext(ctx, ` + INSERT INTO workflow_signoffs (id, instance_id, stage, user_id, decision, comment, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + so.ID, so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment, + so.CreatedAt.Format(timeFmt)) + return err +} + +func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, instance_id, stage, user_id, decision, comment, created_at + FROM workflow_signoffs + WHERE instance_id = ? AND stage = ? + ORDER BY created_at`, instanceID, stage) + if err != nil { + return nil, err + } + defer rows.Close() + var result []models.WorkflowSignoff + for rows.Next() { + var so models.WorkflowSignoff + if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID, + &so.Decision, &so.Comment, st(&so.CreatedAt)); err != nil { + return nil, err + } + result = append(result, so) + } + return result, rows.Err() +} + +func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM workflow_signoffs + WHERE instance_id = ? AND stage = ? AND decision = ?`, + instanceID, stage, decision).Scan(&count) + return count, err +} + func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go index 5d37bd6..a1ab2e7 100644 --- a/server/store/workflow_iface.go +++ b/server/store/workflow_iface.go @@ -52,4 +52,9 @@ type WorkflowStore interface { ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error) ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error) ListAssignmentsByUser(ctx context.Context, userID string, status string) ([]models.WorkflowAssignment, error) + + // Signoffs (v0.3.4) + CreateSignoff(ctx context.Context, s *models.WorkflowSignoff) error + ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) + CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) } diff --git a/server/workflow/engine.go b/server/workflow/engine.go index 4dfea94..a377b3c 100644 --- a/server/workflow/engine.go +++ b/server/workflow/engine.go @@ -139,6 +139,44 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa } currentStage := stages[currentOrdinal] + // ── Validation gate (v0.3.4) ────────────── + sc := ParseStageConfig(currentStage.StageConfig) + if sc.Validation != nil && sc.Validation.RequiredApprovals > 0 { + // Check for rejections first + rejectCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffReject) + if rejectCount > 0 { + action := sc.Validation.RejectAction + if action == "" || action == "cancel" { + _ = e.Cancel(ctx, instanceID, userID) + e.emit(ctx, "workflow.rejected", instanceID, map[string]any{ + "instance_id": instanceID, + "stage": currentStage.Name, + }) + return e.stores.Workflows.GetInstance(ctx, instanceID) + } + // Reroute to named stage + target, rerr := ResolveStageByName(stages, action) + if rerr != nil { + return nil, fmt.Errorf("reject_action stage %q not found: %w", action, rerr) + } + merged := mergeJSON(inst.StageData, stageData) + if err := e.stores.Workflows.AdvanceStage(ctx, instanceID, stages[target].Name, merged); err != nil { + return nil, fmt.Errorf("reject reroute: %w", err) + } + e.emit(ctx, "workflow.rejected", instanceID, map[string]any{ + "instance_id": instanceID, + "stage": currentStage.Name, + "rerouted_to": stages[target].Name, + }) + return e.stores.Workflows.GetInstance(ctx, instanceID) + } + + approveCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffApprove) + if approveCount < sc.Validation.RequiredApprovals { + return nil, fmt.Errorf("insufficient approvals (%d of %d required)", approveCount, sc.Validation.RequiredApprovals) + } + } + // Merge stage data merged := mergeJSON(inst.StageData, stageData) @@ -301,6 +339,112 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData return e.advanceInternal(ctx, inst.ID, stageData, inst.StartedBy, 0) } +// ── Signoffs (v0.3.4) ───────────────────────── + +// SubmitSignoff records a user's approval or rejection at the current stage boundary. +func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) { + inst, err := e.stores.Workflows.GetInstance(ctx, instanceID) + if err != nil { + return nil, fmt.Errorf("instance not found: %w", err) + } + if inst.Status != models.InstanceStatusActive { + return nil, fmt.Errorf("instance is %s, not active", inst.Status) + } + + // Load version snapshot to get current stage config + ver, err := e.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) + if err != nil { + return nil, fmt.Errorf("version not found: %w", err) + } + var stages []models.WorkflowStage + if err := json.Unmarshal(ver.Snapshot, &stages); err != nil { + return nil, fmt.Errorf("corrupt snapshot: %w", err) + } + + // Find current stage + var currentStage *models.WorkflowStage + for i := range stages { + if stages[i].Name == inst.CurrentStage { + currentStage = &stages[i] + break + } + } + if currentStage == nil { + return nil, fmt.Errorf("current stage %q not found", inst.CurrentStage) + } + + sc := ParseStageConfig(currentStage.StageConfig) + if sc.Validation == nil || sc.Validation.RequiredApprovals == 0 { + return nil, fmt.Errorf("stage %q does not require signoffs", currentStage.Name) + } + + // Role check: if validation has a required_role, verify the user has it + if sc.Validation.RequiredRole != "" && currentStage.AssignmentTeamID != nil { + member, merr := e.stores.Teams.GetMember(ctx, *currentStage.AssignmentTeamID, userID) + if merr != nil { + return nil, fmt.Errorf("user is not a member of the assignment team") + } + if member.Role != sc.Validation.RequiredRole { + return nil, fmt.Errorf("role %q required to sign off (you have %q)", sc.Validation.RequiredRole, member.Role) + } + } + + so := &models.WorkflowSignoff{ + InstanceID: instanceID, + Stage: inst.CurrentStage, + UserID: userID, + Decision: decision, + Comment: comment, + } + if err := e.stores.Workflows.CreateSignoff(ctx, so); err != nil { + return nil, fmt.Errorf("create signoff: %w", err) + } + + e.emit(ctx, "workflow.signoff", instanceID, map[string]any{ + "instance_id": instanceID, + "stage": inst.CurrentStage, + "user_id": userID, + "decision": decision, + }) + + return so, nil +} + +// CheckClaimRole verifies that a user has the required_role (from stage_config) +// to claim an assignment. Returns nil if allowed, error if forbidden. +func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models.WorkflowAssignment, userID string) error { + inst, err := stores.Workflows.GetInstance(ctx, assignment.InstanceID) + if err != nil { + return nil // can't verify — allow claim + } + ver, err := stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) + if err != nil { + return nil + } + var stages []models.WorkflowStage + if err := json.Unmarshal(ver.Snapshot, &stages); err != nil { + return nil + } + + for _, s := range stages { + if s.Name == assignment.Stage { + sc := ParseStageConfig(s.StageConfig) + if sc.RequiredRole == "" { + return nil // no role restriction + } + member, merr := stores.Teams.GetMember(ctx, assignment.TeamID, userID) + if merr != nil { + return fmt.Errorf("not a member of team") + } + if member.Role != sc.RequiredRole { + return fmt.Errorf("role %q required for this stage (you have %q)", sc.RequiredRole, member.Role) + } + return nil + } + } + return nil // stage not found in snapshot — allow +} + // emit publishes an event on the bus. func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) { if e.bus == nil { diff --git a/server/workflow/routing.go b/server/workflow/routing.go index d8ee569..8c9a5bb 100644 --- a/server/workflow/routing.go +++ b/server/workflow/routing.go @@ -24,8 +24,28 @@ type Condition struct { // StageConfig holds non-routing config from stage_config JSON. type StageConfig struct { - AutoAssign string `json:"auto_assign,omitempty"` - OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"` + AutoAssign string `json:"auto_assign,omitempty"` + RequiredRole string `json:"required_role,omitempty"` + Validation *ValidationConfig `json:"validation,omitempty"` + OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"` +} + +// ValidationConfig defines multi-party sign-off requirements for a stage. +type ValidationConfig struct { + RequiredApprovals int `json:"required_approvals"` // e.g. 2 + RequiredRole string `json:"required_role,omitempty"` // role needed to sign off + RejectAction string `json:"reject_action,omitempty"` // "cancel" | stage name +} + +// ParseStageConfig unmarshals a stage_config JSON blob into StageConfig. +// Returns zero-value StageConfig on empty/invalid input. +func ParseStageConfig(raw json.RawMessage) StageConfig { + var sc StageConfig + if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { + return sc + } + _ = json.Unmarshal(raw, &sc) + return sc } // OnAdvanceReference points to a Starlark hook for on_advance processing. diff --git a/src/js/sw/surfaces/team-admin/members.js b/src/js/sw/surfaces/team-admin/members.js index b11938d..8df11e7 100644 --- a/src/js/sw/surfaces/team-admin/members.js +++ b/src/js/sw/surfaces/team-admin/members.js @@ -1,5 +1,6 @@ /** * Team Admin > Members + * v0.3.4: custom team roles support */ const { html } = window; const { useState, useEffect, useCallback } = hooks; @@ -9,6 +10,16 @@ export default function MembersSection({ teamId }) { const [loading, setLoading] = useState(true); const [showAdd, setShowAdd] = useState(false); const [users, setUsers] = useState([]); + const [roles, setRoles] = useState(['admin', 'member']); + const [showRoles, setShowRoles] = useState(false); + const [newRole, setNewRole] = useState(''); + + const loadRoles = useCallback(async () => { + try { + const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`); + setRoles(resp.data || ['admin', 'member']); + } catch { setRoles(['admin', 'member']); } + }, [teamId]); const load = useCallback(async () => { try { @@ -18,7 +29,7 @@ export default function MembersSection({ teamId }) { finally { setLoading(false); } }, [teamId]); - useEffect(() => { load(); }, [load]); + useEffect(() => { load(); loadRoles(); }, [load, loadRoles]); async function openAdd() { setShowAdd(true); @@ -83,8 +94,7 @@ export default function MembersSection({ teamId }) {
@@ -102,13 +112,58 @@ export default function MembersSection({ teamId }) { ${m.username || m.user_id} `)} + +
+
+

Team Roles

+ +
+
+ ${roles.map(r => html`${r}`)} +
+ ${showRoles && html` +
+ setNewRole(e.target.value)} + placeholder="New role name" style="width:150px;" /> + +
+
+ ${roles.filter(r => r !== 'admin' && r !== 'member').map(r => html` +
+ ${r} + +
+ `)} + ${roles.filter(r => r !== 'admin' && r !== 'member').length === 0 && + html`
No custom roles
`} +
+ `} +
`; } diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js index 81c26fe..28c37ed 100644 --- a/src/js/sw/surfaces/team-admin/workflows.js +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -288,7 +288,29 @@ function StageForm({ stage, teams, onSave, onCancel }) { const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); const [sla, setSla] = useState(stage?.sla_seconds || ''); + // v0.3.4: stage_config fields + const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {}; + const [requiredRole, setRequiredRole] = useState(sc.required_role || ''); + const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || ''); + const [valRole, setValRole] = useState(sc.validation?.required_role || ''); + const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel'); + const [teamRoles, setTeamRoles] = useState(['admin', 'member']); + + useEffect(() => { + if (!assignTeam) return; + sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {}); + }, [assignTeam]); + function submit() { + const stageConfig = {}; + if (requiredRole) stageConfig.required_role = requiredRole; + if (valApprovals) { + stageConfig.validation = { + required_approvals: parseInt(valApprovals, 10), + ...(valRole ? { required_role: valRole } : {}), + reject_action: valReject || 'cancel', + }; + } onSave({ name, stage_mode: mode, @@ -298,6 +320,7 @@ function StageForm({ stage, teams, onSave, onCancel }) { assignment_team_id: assignTeam || null, auto_transition: autoTransition, sla_seconds: sla ? parseInt(sla, 10) : null, + stage_config: Object.keys(stageConfig).length ? stageConfig : {}, }); } @@ -351,6 +374,40 @@ function StageForm({ stage, teams, onSave, onCancel }) { setSla(e.target.value)} placeholder="e.g. 3600" /> + ${assignTeam && html` +
+
+ + +
+
+
+ Multi-party Validation +
+
+ + setValApprovals(e.target.value)} placeholder="0 = none" /> +
+
+ + +
+
+ + +
+
+
+ `}