Feat v0.3.4 team roles signoff (#18)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #18.
This commit is contained in:
86
CHANGELOG.md
86
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
|
||||
|
||||
10
ROADMAP.md
10
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
|
||||
|
||||
|
||||
49
docs/DESIGN-EXTENSION-LIFECYCLE.md
Normal file
49
docs/DESIGN-EXTENSION-LIFECYCLE.md
Normal file
@@ -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.
|
||||
57
docs/DESIGN-TRIGGER-COMPOSITION.md
Normal file
57
docs/DESIGN-TRIGGER-COMPOSITION.md
Normal file
@@ -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.
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
72
server/handlers/workflow_signoff_handlers.go
Normal file
72
server/handlers/workflow_signoff_handlers.go
Normal file
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
<div class="form-group"><label>Role</label>
|
||||
<select name="role">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,13 +112,58 @@ export default function MembersSection({ teamId }) {
|
||||
<strong style="flex:1;">${m.username || m.user_id}</strong>
|
||||
<select value=${m.role || 'member'} style="width:100px;"
|
||||
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<h4 style="margin:0;flex:1;">Team Roles</h4>
|
||||
<button class="btn-small" onClick=${() => setShowRoles(!showRoles)}>
|
||||
${showRoles ? 'Hide' : 'Manage'}
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
|
||||
${roles.map(r => html`<span key=${r} class="badge">${r}</span>`)}
|
||||
</div>
|
||||
${showRoles && html`
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input value=${newRole} onInput=${e => setNewRole(e.target.value)}
|
||||
placeholder="New role name" style="width:150px;" />
|
||||
<button class="btn-small btn-primary" onClick=${async () => {
|
||||
if (!newRole.trim()) return;
|
||||
const updated = [...new Set([...roles, newRole.trim()])];
|
||||
try {
|
||||
await sw.api.put('/api/v1/teams/' + teamId + '/roles', { roles: updated });
|
||||
setRoles(updated);
|
||||
setNewRole('');
|
||||
sw.toast('Role added', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}}>Add Role</button>
|
||||
</div>
|
||||
<div class="admin-list" style="margin-top:8px;">
|
||||
${roles.filter(r => r !== 'admin' && r !== 'member').map(r => html`
|
||||
<div class="admin-surface-row" key=${r}>
|
||||
<span style="flex:1;">${r}</span>
|
||||
<button class="btn-small btn-danger" onClick=${async () => {
|
||||
const updated = roles.filter(x => x !== r);
|
||||
try {
|
||||
await sw.api.put('/api/v1/teams/' + teamId + '/roles', { roles: updated });
|
||||
setRoles(updated);
|
||||
sw.toast('Role removed', 'success');
|
||||
load(); // re-check members
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
${roles.filter(r => r !== 'admin' && r !== 'member').length === 0 &&
|
||||
html`<div class="empty-hint">No custom roles</div>`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
${assignTeam && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Required Role (claim)</label>
|
||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||
<div class="form-row" style="margin-top:4px;">
|
||||
<div class="form-group">
|
||||
<label>Required Approvals</label>
|
||||
<input type="number" min="0" value=${valApprovals}
|
||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signoff Role</label>
|
||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On Reject</label>
|
||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||
<option value="cancel">Cancel instance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
|
||||
<span class="toggle-track"></span><span>Auto-advance when complete</span>
|
||||
@@ -465,6 +522,7 @@ function AssignmentsTab({ teamId }) {
|
||||
function MonitorTab({ teamId }) {
|
||||
const [instances, setInstances] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedSignoff, setExpandedSignoff] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -501,10 +559,13 @@ function MonitorTab({ teamId }) {
|
||||
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-small"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="btn-small"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
@@ -512,3 +573,61 @@ function MonitorTab({ teamId }) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Signoff Panel (v0.3.4) ──────────────────
|
||||
|
||||
function SignoffPanel({ instanceId, teamId }) {
|
||||
const [signoffs, setSignoffs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
|
||||
setSignoffs(resp.data || []);
|
||||
} catch { setSignoffs([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function submit(decision) {
|
||||
try {
|
||||
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
|
||||
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
|
||||
setComment('');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
|
||||
|
||||
const approvals = signoffs.filter(s => s.decision === 'approve').length;
|
||||
const rejections = signoffs.filter(s => s.decision === 'reject').length;
|
||||
|
||||
return html`
|
||||
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
|
||||
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input value=${comment} onInput=${e => setComment(e.target.value)}
|
||||
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
|
||||
<button class="btn-small btn-success" onClick=${() => submit('approve')}>Approve</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => submit('reject')}>Reject</button>
|
||||
</div>
|
||||
${signoffs.length > 0 && html`
|
||||
<div style="margin-top:6px;">
|
||||
${signoffs.map(s => html`
|
||||
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
|
||||
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
|
||||
<span>${s.user_id}</span>
|
||||
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user