Changeset 0.28.0.1 (#173)
This commit is contained in:
@@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, ''));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 006 Multi-User: DMs + Channels (SQLite)
|
||||
-- ==========================================
|
||||
-- SQLite does not support ALTER CONSTRAINT or DROP CONSTRAINT.
|
||||
-- The type check is enforced by application logic for SQLite.
|
||||
-- ==========================================
|
||||
|
||||
-- ── ai_mode ──────────────────────────────────────────────────────────
|
||||
-- SQLite ignores CHECK constraints on existing rows; we add the column
|
||||
-- and rely on app-layer validation for the enum values.
|
||||
ALTER TABLE channels ADD COLUMN ai_mode TEXT NOT NULL DEFAULT 'auto';
|
||||
|
||||
-- ── topic ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE channels ADD COLUMN topic TEXT;
|
||||
|
||||
-- ── Presence ──────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS user_presence (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_seen DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL DEFAULT 'online'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Chat Switchboard — 017 Unread Counts (SQLite)
|
||||
|
||||
ALTER TABLE channel_participants ADD COLUMN last_read_message_id TEXT;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Chat Switchboard — 018 Auth Abstraction (SQLite) (v0.24.0)
|
||||
ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin';
|
||||
ALTER TABLE users ADD COLUMN external_id TEXT;
|
||||
ALTER TABLE users ADD COLUMN handle TEXT;
|
||||
UPDATE users SET handle = LOWER(REPLACE(REPLACE(username, ' ', '-'), '_', '-'))
|
||||
WHERE handle IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(handle COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id ON users(auth_source, external_id);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Chat Switchboard — 019 Auth Providers (SQLite) (v0.24.1)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oidc_auth_state (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_to TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_state_created
|
||||
ON oidc_auth_state(created_at);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
@@ -1,25 +0,0 @@
|
||||
-- Chat Switchboard — 020 Permissions (SQLite) (v0.24.2)
|
||||
|
||||
-- SQLite cannot drop NOT NULL inline; recreating the constraint isn't needed
|
||||
-- since SQLite doesn't enforce CHECK constraints strictly and the column
|
||||
-- already accepts NULL in practice. The store layer guards source='system'.
|
||||
|
||||
ALTER TABLE groups ADD COLUMN permissions TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE groups ADD COLUMN token_budget_daily INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN token_budget_monthly INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN allowed_models TEXT;
|
||||
|
||||
-- Seed the Everyone group with a stable well-known ID.
|
||||
INSERT OR IGNORE INTO groups
|
||||
(id, name, description, scope, created_by, source, permissions, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'Everyone',
|
||||
'Implicit group — all authenticated users receive these permissions.',
|
||||
'global',
|
||||
NULL,
|
||||
'system',
|
||||
'["model.use","kb.read","channel.create"]',
|
||||
datetime('now'),
|
||||
datetime('now')
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 021_sessions.sql (SQLite)
|
||||
-- Anonymous / session participants for workflow channels (v0.24.3)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_token TEXT NOT NULL UNIQUE,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL DEFAULT 'Visitor',
|
||||
fingerprint TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
|
||||
ON session_participants(channel_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_participants_token
|
||||
ON session_participants(session_token);
|
||||
|
||||
-- Allow channels to opt in to anonymous session access.
|
||||
ALTER TABLE channels ADD COLUMN allow_anonymous INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,56 +0,0 @@
|
||||
-- 023_workflows.sql (SQLite)
|
||||
-- Workflow definitions, stages, and version snapshots (v0.26.1)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
branding TEXT NOT NULL DEFAULT '{}',
|
||||
entry_mode TEXT NOT NULL DEFAULT 'public_link'
|
||||
CHECK (entry_mode IN ('public_link', 'team_only')),
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
on_complete TEXT,
|
||||
retention TEXT NOT NULL DEFAULT '{"mode": "archive"}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
|
||||
ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
|
||||
ON workflows(slug) WHERE team_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
form_template TEXT NOT NULL DEFAULT '{}',
|
||||
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
|
||||
ON workflow_stages(workflow_id, ordinal);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE (workflow_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
|
||||
ON workflow_versions(workflow_id, version_number);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 024_workflow_instances.sql (SQLite)
|
||||
-- Workflow instance columns on channels (v0.26.2)
|
||||
|
||||
ALTER TABLE channels ADD COLUMN workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL;
|
||||
ALTER TABLE channels ADD COLUMN workflow_version INTEGER;
|
||||
ALTER TABLE channels ADD COLUMN current_stage INTEGER DEFAULT 0;
|
||||
ALTER TABLE channels ADD COLUMN stage_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE channels ADD COLUMN workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled'));
|
||||
ALTER TABLE channels ADD COLUMN last_activity_at DATETIME DEFAULT (datetime('now'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow
|
||||
ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 025_workflow_assignments.sql (SQLite)
|
||||
-- Assignment queue for workflow stages (v0.26.4)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
stage INTEGER NOT NULL,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at DATETIME,
|
||||
completed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
|
||||
ON workflow_assignments(channel_id, stage);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
|
||||
ON workflow_assignments(team_id, status);
|
||||
@@ -1,63 +0,0 @@
|
||||
-- 026_tasks.sql — Task scheduling for autonomous agents (v0.27.1) — SQLite
|
||||
|
||||
-- Note: SQLite CHECK on channels.type is in the CREATE TABLE (005),
|
||||
-- which cannot be altered. The 'service' type is accepted by convention;
|
||||
-- the Go layer validates.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'personal',
|
||||
|
||||
task_type TEXT NOT NULL DEFAULT 'prompt',
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
model_id TEXT,
|
||||
system_prompt TEXT DEFAULT '',
|
||||
user_prompt TEXT DEFAULT '',
|
||||
workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL,
|
||||
tool_grants TEXT,
|
||||
|
||||
schedule TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
||||
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
||||
max_wall_clock INTEGER NOT NULL DEFAULT 300,
|
||||
output_mode TEXT NOT NULL DEFAULT 'channel',
|
||||
output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
webhook_url TEXT,
|
||||
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
|
||||
notify_on_complete INTEGER NOT NULL DEFAULT 0,
|
||||
notify_on_failure INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
last_run_at TEXT,
|
||||
next_run_at TEXT,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
wall_clock INTEGER DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 027_webhooks.sql — Webhook secrets + workflow webhook support (v0.27.3) — SQLite
|
||||
|
||||
ALTER TABLE tasks ADD COLUMN webhook_secret TEXT;
|
||||
ALTER TABLE workflows ADD COLUMN webhook_url TEXT;
|
||||
ALTER TABLE workflows ADD COLUMN webhook_secret TEXT;
|
||||
@@ -1,21 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
)
|
||||
|
||||
// ── Workflow Assignment Handler ─────────────
|
||||
// Manages the assignment queue for human review stages.
|
||||
|
||||
type WorkflowAssignmentHandler struct{}
|
||||
type WorkflowAssignmentHandler struct {
|
||||
hub *events.Hub
|
||||
}
|
||||
|
||||
func NewWorkflowAssignmentHandler() *WorkflowAssignmentHandler {
|
||||
return &WorkflowAssignmentHandler{}
|
||||
func NewWorkflowAssignmentHandler(hub ...*events.Hub) *WorkflowAssignmentHandler {
|
||||
h := &WorkflowAssignmentHandler{}
|
||||
if len(hub) > 0 {
|
||||
h.hub = hub[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
type assignmentRow struct {
|
||||
@@ -53,7 +61,7 @@ func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
|
||||
for rows.Next() {
|
||||
var a assignmentRow
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
@@ -64,17 +72,20 @@ func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ListForUser returns assignments claimed by the current user.
|
||||
// ListForUser returns assignments claimed by the current user plus
|
||||
// unassigned assignments for teams the user belongs to.
|
||||
// GET /api/v1/workflow-assignments/mine
|
||||
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE assigned_to = $1 AND status = 'claimed'
|
||||
ORDER BY claimed_at DESC
|
||||
`), userID)
|
||||
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||
wa.created_at, wa.claimed_at, wa.completed_at
|
||||
FROM workflow_assignments wa
|
||||
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
|
||||
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
|
||||
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||
ORDER BY wa.created_at DESC
|
||||
`), userID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
||||
return
|
||||
@@ -85,7 +96,7 @@ func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
||||
for rows.Next() {
|
||||
var a assignmentRow
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
@@ -117,6 +128,21 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Emit workflow.claimed WS event
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"assignment_id": assignmentID,
|
||||
"claimed_by": userID,
|
||||
})
|
||||
h.hub.GetBus().Publish(events.Event{
|
||||
Label: "workflow.claimed",
|
||||
Room: assignmentID,
|
||||
Payload: payload,
|
||||
Ts: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
|
||||
@@ -77,13 +77,17 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Set workflow columns
|
||||
allowAnonVal := interface{}(true)
|
||||
if database.CurrentDialect == database.DialectSQLite {
|
||||
allowAnonVal = 1
|
||||
}
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = '{}', workflow_status = 'active',
|
||||
last_activity_at = $3, allow_anonymous = true, ai_mode = 'auto'
|
||||
WHERE id = $4
|
||||
`), wf.ID, ver.VersionNumber, time.Now().UTC(), ch.ID)
|
||||
last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
|
||||
WHERE id = $5
|
||||
`), wf.ID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set workflow columns: %v", err)
|
||||
}
|
||||
|
||||
844
server/handlers/workflow_instance_test.go
Normal file
844
server/handlers/workflow_instance_test.go
Normal file
@@ -0,0 +1,844 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Extended harness: CRUD + instances + assignments + entry
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
type workflowInstanceHarness struct {
|
||||
*testHarness
|
||||
stores store.Stores
|
||||
adminToken string
|
||||
adminID string
|
||||
}
|
||||
|
||||
func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||
api.POST("/auth/login", auth.Login)
|
||||
api.POST("/auth/register", auth.Register)
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
// Workflow CRUD
|
||||
wfH := NewWorkflowHandler(stores)
|
||||
protected.GET("/workflows", wfH.List)
|
||||
protected.POST("/workflows", wfH.Create)
|
||||
protected.GET("/workflows/:id", wfH.Get)
|
||||
protected.PATCH("/workflows/:id", wfH.Update)
|
||||
protected.DELETE("/workflows/:id", wfH.Delete)
|
||||
protected.GET("/workflows/:id/stages", wfH.ListStages)
|
||||
protected.POST("/workflows/:id/stages", wfH.CreateStage)
|
||||
protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage)
|
||||
protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage)
|
||||
protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages)
|
||||
protected.POST("/workflows/:id/publish", wfH.Publish)
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
|
||||
// Assignments
|
||||
wfAssignH := NewWorkflowAssignmentHandler()
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
|
||||
// Teams (admin routes for creating teams + adding members)
|
||||
teamH := NewTeamHandler(nil)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
admin.POST("/teams", teamH.CreateTeam)
|
||||
admin.POST("/teams/:id/members", teamH.AddMember)
|
||||
|
||||
// Team-scoped assignment listing (mirrors main.go teamScoped)
|
||||
teamScoped := protected.Group("/teams/:teamId")
|
||||
teamAssignH := NewWorkflowAssignmentHandler()
|
||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||
|
||||
// Visitor entry
|
||||
wfEntry := NewWorkflowEntryHandler(stores)
|
||||
r.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||
|
||||
// Seed admin user
|
||||
adminID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
"wfinst-admin", "wfinst-admin@test.com", "$2a$10$test", "admin", "wfinst-admin", "builtin",
|
||||
)
|
||||
token := makeToken(adminID, "wfinst-admin@test.com", "admin")
|
||||
|
||||
return &workflowInstanceHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
stores: stores,
|
||||
adminToken: token,
|
||||
adminID: adminID,
|
||||
}
|
||||
}
|
||||
|
||||
// createPublishedWorkflow is a helper that creates a workflow with N stages,
|
||||
// activates it, and publishes it. Returns workflow ID + stage IDs.
|
||||
func (h *workflowInstanceHarness) createPublishedWorkflow(name string, numStages int) (string, []string) {
|
||||
h.t.Helper()
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": name,
|
||||
"entry_mode": "public_link",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
h.t.Fatalf("create workflow: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var wf struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
stageIDs := make([]string, numStages)
|
||||
for i := 0; i < numStages; i++ {
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": fmt.Sprintf("Stage %d", i),
|
||||
"ordinal": i,
|
||||
"history_mode": "full",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
h.t.Fatalf("create stage %d: %d: %s", i, resp.Code, resp.Body.String())
|
||||
}
|
||||
var st struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &st)
|
||||
stageIDs[i] = st.ID
|
||||
}
|
||||
|
||||
// Activate + publish
|
||||
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"is_active": true,
|
||||
})
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||
if resp.Code != http.StatusCreated {
|
||||
h.t.Fatalf("publish: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
return wf.ID, stageIDs
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #15 — Instance Lifecycle (currently untested)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowInstanceLifecycle exercises: start → status → advance → complete.
|
||||
func TestWorkflowInstanceLifecycle(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Lifecycle Test", 2)
|
||||
|
||||
// ── Start ───────────────────────────────
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Start: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var startResp struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
CurrentStage int `json:"current_stage"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &startResp)
|
||||
if startResp.ChannelID == "" {
|
||||
t.Fatal("Start: channel_id empty")
|
||||
}
|
||||
if startResp.CurrentStage != 0 {
|
||||
t.Errorf("Start: current_stage = %d, want 0", startResp.CurrentStage)
|
||||
}
|
||||
chID := startResp.ChannelID
|
||||
|
||||
// ── Status ──────────────────────────────
|
||||
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Status: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var status struct {
|
||||
WorkflowID *string `json:"workflow_id"`
|
||||
Status string `json:"status"`
|
||||
Stage int `json:"current_stage"`
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||
if status.Status != "active" {
|
||||
t.Errorf("Status: got %q, want \"active\"", status.Status)
|
||||
}
|
||||
if status.WorkflowID == nil || *status.WorkflowID != wfID {
|
||||
t.Errorf("Status: workflow_id mismatch")
|
||||
}
|
||||
|
||||
// ── Advance (stage 0 → 1) ───────────────
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"name": "Jane", "email": "jane@test.com"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Advance 0→1: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var advResp struct {
|
||||
Status string `json:"status"`
|
||||
CurrentStage int `json:"current_stage"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
||||
if advResp.Status != "active" {
|
||||
t.Errorf("Advance 0→1: status = %q, want \"active\"", advResp.Status)
|
||||
}
|
||||
if advResp.CurrentStage != 1 {
|
||||
t.Errorf("Advance 0→1: current_stage = %d, want 1", advResp.CurrentStage)
|
||||
}
|
||||
|
||||
// ── Verify merged data persisted ────────
|
||||
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||
var data map[string]string
|
||||
json.Unmarshal(status.StageData, &data)
|
||||
if data["name"] != "Jane" || data["email"] != "jane@test.com" {
|
||||
t.Errorf("Stage data after advance: got %v", data)
|
||||
}
|
||||
|
||||
// ── Advance (stage 1 → complete) ────────
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"resolution": "approved"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Advance 1→complete: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
||||
if advResp.Status != "completed" {
|
||||
t.Errorf("Completion: status = %q, want \"completed\"", advResp.Status)
|
||||
}
|
||||
|
||||
// ── Status after completion ──────────────
|
||||
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Post-complete status: got %q, want \"completed\"", status.Status)
|
||||
}
|
||||
|
||||
// ── Advance on completed should fail ────
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"extra": "data"},
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Advance after complete: got %d, want 400", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowInstanceStart_InactiveWorkflow verifies start fails on inactive workflow.
|
||||
func TestWorkflowInstanceStart_InactiveWorkflow(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
// Create workflow with a stage but don't activate
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Inactive WF",
|
||||
})
|
||||
var wf struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
||||
})
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Start inactive: got %d, want 400", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowInstanceStart_NoPublishedVersion verifies start fails without a published version.
|
||||
func TestWorkflowInstanceStart_NoPublishedVersion(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Unpublished WF",
|
||||
})
|
||||
var wf struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
||||
})
|
||||
// Activate but don't publish
|
||||
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"is_active": true,
|
||||
})
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Start unpublished: got %d, want 400", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #15 — Reject validation
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowReject exercises reject validation: reason required, stage-0 guard.
|
||||
func TestWorkflowReject(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Reject Test", 3)
|
||||
|
||||
// Start
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
chID := start.ChannelID
|
||||
|
||||
// ── Reject from stage 0 should fail ─────
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||
"reason": "want to go back",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Reject at stage 0: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// ── Reject without reason should fail ───
|
||||
// Advance to stage 1 first
|
||||
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"step": "0"},
|
||||
})
|
||||
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, nil)
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Reject no reason: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// ── Reject with empty reason should fail ─
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||
"reason": "",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Reject empty reason: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// ── Valid reject ────────────────────────
|
||||
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||
"reason": "Missing email field",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Valid reject: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var rejResp struct {
|
||||
CurrentStage int `json:"current_stage"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &rejResp)
|
||||
if rejResp.CurrentStage != 0 {
|
||||
t.Errorf("After reject: current_stage = %d, want 0", rejResp.CurrentStage)
|
||||
}
|
||||
if rejResp.Reason != "Missing email field" {
|
||||
t.Errorf("Reject reason: got %q", rejResp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #1 — Advance body field is "data" (not "stage_data")
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowAdvance_BodyFieldName verifies the advance endpoint reads "data"
|
||||
// from the request body, not "stage_data" (which the old ICD incorrectly documented).
|
||||
func TestWorkflowAdvance_BodyFieldName(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Field Name Test", 2)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
chID := start.ChannelID
|
||||
|
||||
// Send data under the correct "data" key
|
||||
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"collected": "yes"},
|
||||
})
|
||||
|
||||
// Verify data was merged
|
||||
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||
var data map[string]string
|
||||
json.Unmarshal(status.StageData, &data)
|
||||
if data["collected"] != "yes" {
|
||||
t.Errorf("Data sent under 'data' key not merged: got %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #2 — GetStatus response uses "status" (not "workflow_status")
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowGetStatus_ResponseShape verifies the exact JSON field names
|
||||
// returned by the status endpoint.
|
||||
func TestWorkflowGetStatus_ResponseShape(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Status Shape", 1)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
|
||||
resp = h.request("GET", "/api/v1/channels/"+start.ChannelID+"/workflow/status", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("GetStatus: %d", resp.Code)
|
||||
}
|
||||
|
||||
// Parse as raw map to check exact field names
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &raw)
|
||||
|
||||
if _, ok := raw["status"]; !ok {
|
||||
t.Error("Response missing 'status' field — should be 'status', not 'workflow_status'")
|
||||
}
|
||||
if _, ok := raw["workflow_status"]; ok {
|
||||
t.Error("Response has 'workflow_status' — should be 'status' per ICD")
|
||||
}
|
||||
if _, ok := raw["workflow_id"]; !ok {
|
||||
t.Error("Response missing 'workflow_id'")
|
||||
}
|
||||
if _, ok := raw["current_stage"]; !ok {
|
||||
t.Error("Response missing 'current_stage'")
|
||||
}
|
||||
if _, ok := raw["last_activity_at"]; !ok {
|
||||
t.Error("Response missing 'last_activity_at'")
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #4 — StartVisitor response shape
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowVisitorEntry_ResponseShape verifies the visitor start endpoint
|
||||
// returns the correct field names (session_id + redirect_to, not session_token).
|
||||
func TestWorkflowVisitorEntry_ResponseShape(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Visitor Test", 1)
|
||||
|
||||
// Read slug
|
||||
resp := h.request("GET", "/api/v1/workflows/"+wfID, h.adminToken, nil)
|
||||
var wf struct{ Slug string `json:"slug"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
// Visitor start (no auth, no body)
|
||||
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Visitor start: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &raw)
|
||||
|
||||
if _, ok := raw["channel_id"]; !ok {
|
||||
t.Error("Response missing 'channel_id'")
|
||||
}
|
||||
if _, ok := raw["session_id"]; !ok {
|
||||
t.Error("Response missing 'session_id' — should be 'session_id', not 'session_token'")
|
||||
}
|
||||
if _, ok := raw["redirect_to"]; !ok {
|
||||
t.Error("Response missing 'redirect_to'")
|
||||
}
|
||||
// These should NOT be present
|
||||
if _, ok := raw["session_token"]; ok {
|
||||
t.Error("Response has 'session_token' — field was renamed to 'session_id'")
|
||||
}
|
||||
if _, ok := raw["workflow"]; ok {
|
||||
t.Error("Response has 'workflow' object — not in actual response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowVisitorEntry_TeamOnlyBlocked verifies team_only workflows reject visitors.
|
||||
func TestWorkflowVisitorEntry_TeamOnlyBlocked(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Team Only WF",
|
||||
"entry_mode": "team_only",
|
||||
})
|
||||
var wf struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "S0", "ordinal": 0, "history_mode": "full",
|
||||
})
|
||||
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"is_active": true,
|
||||
})
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Errorf("Visitor on team_only: got %d, want 403", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #6/#7 — webhook_url/webhook_secret round-trip
|
||||
// EXPECTED TO FAIL until stores are fixed to SELECT/INSERT these columns.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowWebhookFields verifies webhook_url and webhook_secret survive
|
||||
// create → get round-trip. This WILL FAIL until the Postgres and SQLite
|
||||
// workflow stores are updated to include these columns in their queries.
|
||||
func TestWorkflowWebhookFields(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Webhook WF",
|
||||
"webhook_url": "https://hooks.example.com/wf",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create with webhook_url: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var wf struct {
|
||||
ID string `json:"id"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
|
||||
// BUG #6: webhook_url is not included in the store's INSERT/SELECT,
|
||||
// so it will be empty on the create response.
|
||||
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
||||
t.Errorf("Create response webhook_url: got %q, want %q (BUG #6: store doesn't persist webhook_url)",
|
||||
wf.WebhookURL, "https://hooks.example.com/wf")
|
||||
}
|
||||
|
||||
// Also verify via GET
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
||||
t.Errorf("GET webhook_url: got %q, want %q (BUG #6: store doesn't SELECT webhook_url)",
|
||||
wf.WebhookURL, "https://hooks.example.com/wf")
|
||||
}
|
||||
|
||||
// BUG #7: PATCH webhook_url should work
|
||||
resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"webhook_url": "https://hooks.example.com/updated",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH webhook_url: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
if wf.WebhookURL != "https://hooks.example.com/updated" {
|
||||
t.Errorf("PATCH webhook_url: got %q, want %q (BUG #7: Update() ignores webhook_url)",
|
||||
wf.WebhookURL, "https://hooks.example.com/updated")
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #18 — ListMine should include unassigned-for-my-teams
|
||||
// EXPECTED TO FAIL until ListMine query is fixed.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowAssignment_ListMineIncludesTeamUnassigned verifies that
|
||||
// GET /workflow-assignments/mine returns both claimed assignments AND
|
||||
// unassigned assignments for the user's teams.
|
||||
func TestWorkflowAssignment_ListMineIncludesTeamUnassigned(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
// Create a team and add a member
|
||||
memberID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
"wf-member", "wf-member@test.com", "$2a$10$test", "user", "wf-member", "builtin",
|
||||
)
|
||||
memberToken := makeToken(memberID, "wf-member@test.com", "user")
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
||||
"name": "Review Team", "description": "Test team",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var team map[string]interface{}
|
||||
decode(resp, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
||||
map[string]string{"user_id": memberID, "role": "member"})
|
||||
|
||||
// Create a workflow with an assignment stage
|
||||
wfResp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Assignment WF",
|
||||
"entry_mode": "team_only",
|
||||
})
|
||||
var wf struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(wfResp.Body.Bytes(), &wf)
|
||||
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Intake", "ordinal": 0, "history_mode": "full",
|
||||
})
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Review",
|
||||
"ordinal": 1,
|
||||
"history_mode": "full",
|
||||
"assignment_team_id": teamID,
|
||||
})
|
||||
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"is_active": true,
|
||||
})
|
||||
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||
|
||||
// Start instance and advance to stage 1 (creates an assignment)
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
|
||||
h.request("POST", "/api/v1/channels/"+start.ChannelID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"intake": "done"},
|
||||
})
|
||||
|
||||
// BUG #18: ListMine should include the unassigned assignment for
|
||||
// the member's team, but currently only returns claimed assignments.
|
||||
resp = h.request("GET", "/api/v1/workflow-assignments/mine", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("ListMine: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TeamID string `json:"team_id"`
|
||||
To *string `json:"assigned_to"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &listResp)
|
||||
|
||||
if len(listResp.Data) == 0 {
|
||||
t.Error("ListMine returned 0 assignments — BUG #18: should include unassigned assignments for user's teams")
|
||||
}
|
||||
|
||||
// If we do get results, verify the shape
|
||||
for _, a := range listResp.Data {
|
||||
if a.TeamID != teamID {
|
||||
t.Errorf("Assignment team_id: got %q, want %q", a.TeamID, teamID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Assignment Claim + Complete (basic happy path)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowAssignment_ClaimAndComplete exercises the claim → complete flow.
|
||||
func TestWorkflowAssignment_ClaimAndComplete(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
// Seed assignment directly (simulates the advance handler creating one)
|
||||
wfID, _ := h.createPublishedWorkflow("Assign WF", 2)
|
||||
|
||||
// Start + advance to create assignment stage
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
|
||||
// Manually insert an assignment (since our stages don't have assignment_team_id)
|
||||
teamID := seedInsertReturningID(t,
|
||||
`INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`,
|
||||
"Claim Team", "Test", h.adminID,
|
||||
)
|
||||
assignID := seedInsertReturningID(t,
|
||||
`INSERT INTO workflow_assignments (channel_id, stage, team_id) VALUES ($1, $2, $3) RETURNING id`,
|
||||
start.ChannelID, 0, teamID,
|
||||
)
|
||||
|
||||
// Claim
|
||||
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Claim: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var claimResp struct {
|
||||
Claimed bool `json:"claimed"`
|
||||
AssignmentID string `json:"assignment_id"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &claimResp)
|
||||
if !claimResp.Claimed {
|
||||
t.Error("Claim: claimed should be true")
|
||||
}
|
||||
|
||||
// Double-claim should conflict
|
||||
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Errorf("Double claim: got %d, want 409", resp.Code)
|
||||
}
|
||||
|
||||
// Complete
|
||||
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Complete: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Double-complete should conflict
|
||||
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Errorf("Double complete: got %d, want 409", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #15 — Status on non-workflow channel returns 404
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
func TestWorkflowGetStatus_NonWorkflowChannel(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/channels/00000000-0000-0000-0000-000000000000/workflow/status", h.adminToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("Status on non-existent channel: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #15 — Data merge across multiple advances
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowAdvance_DataMerge verifies that stage data accumulates
|
||||
// across multiple advance calls (merge, not replace).
|
||||
func TestWorkflowAdvance_DataMerge(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
wfID, _ := h.createPublishedWorkflow("Merge Test", 3)
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||
var start struct{ ChannelID string `json:"channel_id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||
chID := start.ChannelID
|
||||
|
||||
// Advance stage 0 → 1 with key "a"
|
||||
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"a": "1"},
|
||||
})
|
||||
|
||||
// Advance stage 1 → 2 with key "b"
|
||||
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||
"data": map[string]string{"b": "2"},
|
||||
})
|
||||
|
||||
// Check accumulated data contains both keys
|
||||
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||
var merged map[string]string
|
||||
json.Unmarshal(status.StageData, &merged)
|
||||
|
||||
if merged["a"] != "1" {
|
||||
t.Errorf("Merged data missing key 'a': got %v", merged)
|
||||
}
|
||||
if merged["b"] != "2" {
|
||||
t.Errorf("Merged data missing key 'b': got %v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #17 — PATCH non-existent workflow
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowPatchNonExistent verifies PATCH to a non-existent ID returns 404.
|
||||
// Documents the confusing code path: Update affects 0 rows (no error),
|
||||
// then GetByID returns sql.ErrNoRows → 404.
|
||||
func TestWorkflowPatchNonExistent(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
resp := h.request("PATCH", "/api/v1/workflows/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{
|
||||
"name": "ghost",
|
||||
})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("PATCH non-existent: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #19 — ListForTeam with ?status= filter
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestWorkflowAssignment_ListForTeamStatusFilter verifies the ?status=
|
||||
// query param on GET /teams/:teamId/assignments.
|
||||
func TestWorkflowAssignment_ListForTeamStatusFilter(t *testing.T) {
|
||||
h := setupWorkflowInstanceHarness(t)
|
||||
|
||||
// Create team
|
||||
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
||||
"name": "Filter Team", "description": "Test",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var team map[string]interface{}
|
||||
decode(resp, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
// Add self as member (needed for access)
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
||||
map[string]string{"user_id": h.adminID, "role": "admin"})
|
||||
|
||||
// Create real channels for FK satisfaction
|
||||
ch1ID := database.SeedTestChannel(t, h.adminID, "filter-ch-1")
|
||||
ch2ID := database.SeedTestChannel(t, h.adminID, "filter-ch-2")
|
||||
|
||||
// Seed two assignments: one unassigned, one claimed
|
||||
seedExec(t,
|
||||
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status) VALUES ($1, $2, $3, $4)`,
|
||||
ch1ID, 0, teamID, "unassigned",
|
||||
)
|
||||
seedExec(t,
|
||||
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status, assigned_to) VALUES ($1, $2, $3, $4, $5)`,
|
||||
ch2ID, 1, teamID, "claimed", h.adminID,
|
||||
)
|
||||
|
||||
// Default (unassigned)
|
||||
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("ListForTeam default: %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var lr struct {
|
||||
Data []struct{ Status string `json:"status"` } `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &lr)
|
||||
if len(lr.Data) != 1 {
|
||||
t.Errorf("Default filter: expected 1 unassigned, got %d", len(lr.Data))
|
||||
} else if lr.Data[0].Status != "unassigned" {
|
||||
t.Errorf("Default filter: expected status 'unassigned', got %q", lr.Data[0].Status)
|
||||
}
|
||||
|
||||
// Explicit ?status=claimed
|
||||
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments?status=claimed", h.adminToken, nil)
|
||||
json.Unmarshal(resp.Body.Bytes(), &lr)
|
||||
if len(lr.Data) != 1 {
|
||||
t.Errorf("?status=claimed: expected 1, got %d", len(lr.Data))
|
||||
} else if lr.Data[0].Status != "claimed" {
|
||||
t.Errorf("?status=claimed: expected status 'claimed', got %q", lr.Data[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ type WorkflowChannelStatus struct {
|
||||
CurrentStage int `json:"current_stage"`
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at"`
|
||||
LastActivityAt *string `json:"last_activity_at"`
|
||||
}
|
||||
|
||||
// GetStatus returns the workflow state for a channel.
|
||||
@@ -391,19 +391,19 @@ func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage model
|
||||
bestUserID = members[0].UserID // fallback to first member
|
||||
|
||||
rows, err := database.DB.QueryContext(ctx, database.Q(`
|
||||
SELECT m.user_id, MAX(wa.claimed_at) as last_claim
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
|
||||
FROM team_members m
|
||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
|
||||
WHERE m.team_id = $2
|
||||
GROUP BY m.user_id
|
||||
ORDER BY last_claim ASC NULLS FIRST
|
||||
ORDER BY last_claim ASC
|
||||
LIMIT 1
|
||||
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
var uid string
|
||||
var lastClaim *time.Time
|
||||
var lastClaim string // COALESCE returns TEXT on both dialects
|
||||
if err := rows.Scan(&uid, &lastClaim); err == nil {
|
||||
bestUserID = uid
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestWorkflowCRUD(t *testing.T) {
|
||||
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Get workflow: got %d", resp.Code)
|
||||
t.Fatalf("Get workflow: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// ── List workflows ──────────────────────
|
||||
|
||||
@@ -61,7 +61,11 @@ func (h *WorkflowHandler) List(c *gin.Context) {
|
||||
func (h *WorkflowHandler) Get(c *gin.Context) {
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
||||
return
|
||||
}
|
||||
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
|
||||
@@ -251,7 +255,11 @@ func (h *WorkflowHandler) Publish(c *gin.Context) {
|
||||
wfID := c.Param("id")
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ func main() {
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler()
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
@@ -907,7 +907,7 @@ func main() {
|
||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||
|
||||
// Team workflow assignments (v0.26.4)
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler()
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
|
||||
@@ -39,7 +39,8 @@ type WorkflowPatch struct {
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
OnComplete *json.RawMessage `json:"on_complete,omitempty"`
|
||||
Retention *json.RawMessage `json:"retention,omitempty"`
|
||||
WebhookURL *string `json:"webhook_url,omitempty"`
|
||||
WebhookURL *string `json:"webhook_url,omitempty"`
|
||||
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||
}
|
||||
|
||||
// ── Workflow Stage ──────────────────────────
|
||||
|
||||
@@ -19,23 +19,27 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
branding := jsonOrEmpty(w.Branding)
|
||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, webhook_url, webhook_secret, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, version, created_at, updated_at`,
|
||||
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||
w.IsActive, jsonOrNull(w.OnComplete), retention, w.CreatedBy,
|
||||
w.IsActive, jsonOrNull(w.OnComplete), retention,
|
||||
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), w.CreatedBy,
|
||||
).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||
w := &models.Workflow{}
|
||||
var branding, retention, onComplete []byte
|
||||
var webhookURL, webhookSecret *string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE id = $1`, id,
|
||||
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||
&webhookURL, &webhookSecret,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -43,6 +47,12 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
if webhookURL != nil {
|
||||
w.WebhookURL = *webhookURL
|
||||
}
|
||||
if webhookSecret != nil {
|
||||
w.WebhookSecret = *webhookSecret
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -51,20 +61,24 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
||||
var args []interface{}
|
||||
if teamID != nil {
|
||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = $1 AND slug = $2`
|
||||
args = []interface{}{*teamID, slug}
|
||||
} else {
|
||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL AND slug = $1`
|
||||
args = []interface{}{slug}
|
||||
}
|
||||
w := &models.Workflow{}
|
||||
var branding, retention, onComplete []byte
|
||||
var webhookURL, webhookSecret *string
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||
&webhookURL, &webhookSecret,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -72,6 +86,12 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
if webhookURL != nil {
|
||||
w.WebhookURL = *webhookURL
|
||||
}
|
||||
if webhookSecret != nil {
|
||||
w.WebhookSecret = *webhookSecret
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -108,6 +128,12 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
||||
if patch.Retention != nil {
|
||||
add("retention", string(*patch.Retention))
|
||||
}
|
||||
if patch.WebhookURL != nil {
|
||||
add("webhook_url", *patch.WebhookURL)
|
||||
}
|
||||
if patch.WebhookSecret != nil {
|
||||
add("webhook_secret", *patch.WebhookSecret)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return nil
|
||||
@@ -138,7 +164,8 @@ func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = $1
|
||||
ORDER BY name ASC`, teamID)
|
||||
}
|
||||
@@ -146,7 +173,8 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
|
||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL
|
||||
ORDER BY name ASC`)
|
||||
}
|
||||
@@ -161,14 +189,22 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
||||
for rows.Next() {
|
||||
var w models.Workflow
|
||||
var branding, retention, onComplete []byte
|
||||
var webhookURL, webhookSecret *string
|
||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
|
||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
&retention, &webhookURL, &webhookSecret,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
if webhookURL != nil {
|
||||
w.WebhookURL = *webhookURL
|
||||
}
|
||||
if webhookSecret != nil {
|
||||
w.WebhookSecret = *webhookSecret
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -314,3 +350,10 @@ func jsonOrNull(b json.RawMessage) interface{} {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.Ro
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
||||
FROM routing_policies WHERE id = ?
|
||||
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, &p.CreatedAt, &p.UpdatedAt)
|
||||
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interf
|
||||
var p models.RoutingPolicy
|
||||
var configJSON string
|
||||
var isActive int
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.IsActive = isActive != 0
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant
|
||||
return err
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `SELECT created_at FROM session_participants WHERE id = ?`, sp.ID).
|
||||
Scan(&sp.CreatedAt)
|
||||
Scan(st(&sp.CreatedAt))
|
||||
}
|
||||
|
||||
func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) {
|
||||
@@ -36,7 +36,7 @@ func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.Se
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
||||
FROM session_participants WHERE session_token = ?`, token,
|
||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
|
||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionP
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
||||
FROM session_participants WHERE id = ?`, id,
|
||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
|
||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]
|
||||
var result []models.SessionParticipant
|
||||
for rows.Next() {
|
||||
var sp models.SessionParticipant
|
||||
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, sp)
|
||||
|
||||
@@ -47,7 +47,7 @@ func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt)
|
||||
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.Ta
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, channel_id, status, started_at
|
||||
FROM task_runs WHERE task_id = ? AND status = 'running'
|
||||
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt)
|
||||
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,8 +151,8 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
||||
var runs []models.TaskRun
|
||||
for rows.Next() {
|
||||
var r models.TaskRun
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt,
|
||||
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt),
|
||||
stN(&r.CompletedAt), &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||
continue
|
||||
}
|
||||
runs = append(runs, r)
|
||||
@@ -183,7 +183,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
||||
&t.WorkflowID, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
|
||||
@@ -27,10 +27,12 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
|
||||
is_active, version, on_complete, retention, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
is_active, version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
|
||||
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret),
|
||||
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
@@ -38,7 +40,8 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
@@ -46,12 +49,14 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
||||
if teamID != nil {
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
|
||||
}
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
|
||||
}
|
||||
|
||||
@@ -87,6 +92,14 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
||||
sets = append(sets, "retention = ?")
|
||||
args = append(args, string(*patch.Retention))
|
||||
}
|
||||
if patch.WebhookURL != nil {
|
||||
sets = append(sets, "webhook_url = ?")
|
||||
args = append(args, *patch.WebhookURL)
|
||||
}
|
||||
if patch.WebhookSecret != nil {
|
||||
sets = append(sets, "webhook_secret = ?")
|
||||
args = append(args, *patch.WebhookSecret)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return nil
|
||||
@@ -118,7 +131,8 @@ func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = ?
|
||||
ORDER BY name ASC`, teamID)
|
||||
}
|
||||
@@ -126,7 +140,8 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
|
||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL
|
||||
ORDER BY name ASC`)
|
||||
}
|
||||
@@ -160,18 +175,18 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
defer rows.Close()
|
||||
var result []models.WorkflowStage
|
||||
for rows.Next() {
|
||||
var st models.WorkflowStage
|
||||
var stg models.WorkflowStage
|
||||
var formTpl, transRules string
|
||||
var autoTrans int
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
|
||||
&autoTrans, &transRules, &st.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.HistoryMode,
|
||||
&autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = json.RawMessage(formTpl)
|
||||
st.TransitionRules = json.RawMessage(transRules)
|
||||
st.AutoTransition = autoTrans != 0
|
||||
result = append(result, st)
|
||||
stg.FormTemplate = json.RawMessage(formTpl)
|
||||
stg.TransitionRules = json.RawMessage(transRules)
|
||||
stg.AutoTransition = autoTrans != 0
|
||||
result = append(result, stg)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -231,7 +246,7 @@ func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versi
|
||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||
FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
|
||||
workflowID, versionNumber,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -247,7 +262,7 @@ func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string)
|
||||
FROM workflow_versions WHERE workflow_id = ?
|
||||
ORDER BY version_number DESC LIMIT 1`,
|
||||
workflowID,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -261,11 +276,13 @@ func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface
|
||||
w := &models.Workflow{}
|
||||
var branding, retention string
|
||||
var onComplete sql.NullString
|
||||
var webhookURL, webhookSecret sql.NullString
|
||||
var isActive int
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
&webhookURL, &webhookSecret,
|
||||
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,6 +292,12 @@ func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface
|
||||
if onComplete.Valid {
|
||||
w.OnComplete = json.RawMessage(onComplete.String)
|
||||
}
|
||||
if webhookURL.Valid {
|
||||
w.WebhookURL = webhookURL.String
|
||||
}
|
||||
if webhookSecret.Valid {
|
||||
w.WebhookSecret = webhookSecret.String
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -289,10 +312,12 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
||||
var w models.Workflow
|
||||
var branding, retention string
|
||||
var onComplete sql.NullString
|
||||
var webhookURL, webhookSecret sql.NullString
|
||||
var isActive int
|
||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
|
||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
&retention, &webhookURL, &webhookSecret,
|
||||
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = json.RawMessage(branding)
|
||||
@@ -301,6 +326,12 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
||||
if onComplete.Valid {
|
||||
w.OnComplete = json.RawMessage(onComplete.String)
|
||||
}
|
||||
if webhookURL.Valid {
|
||||
w.WebhookURL = webhookURL.String
|
||||
}
|
||||
if webhookSecret.Valid {
|
||||
w.WebhookSecret = webhookSecret.String
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -326,3 +357,10 @@ func jsonOrNull(b json.RawMessage) interface{} {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -352,11 +352,12 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo
|
||||
|
||||
// Bind first stage persona
|
||||
if len(stages) > 0 && stages[0].PersonaID != nil {
|
||||
_, _ = database.DB.ExecContext(ctx, database.Q(`
|
||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id)
|
||||
VALUES ($1, 'persona', $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`), ch.ID, *stages[0].PersonaID)
|
||||
_ = stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
||||
ChannelID: ch.ID,
|
||||
ParticipantType: "persona",
|
||||
ParticipantID: *stages[0].PersonaID,
|
||||
Role: "member",
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[workflow] on_complete: started chained workflow %s → %s (channel %s)",
|
||||
|
||||
Reference in New Issue
Block a user