Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 1m56s
CI/CD / test-sqlite (pull_request) Successful in 2m47s
CI/CD / build-and-deploy (pull_request) Has been skipped
Drop chat-era columns (persona_id, history_mode) from workflow_stages. Rename transition_rules → stage_config. Add audience, stage_type, starlark_hook, branch_rules columns. Update stage_mode CHECK to (form, review, delegated, automated). All Go stores, handlers, routing engine, Starlark module, and frontend editors updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
404 lines
13 KiB
Go
404 lines
13 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"switchboard-core/models"
|
|
)
|
|
|
|
// WorkflowStore implements store.WorkflowStore for Postgres.
|
|
type WorkflowStore struct{}
|
|
|
|
func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
|
|
|
|
// ── Workflow CRUD ───────────────────────────
|
|
|
|
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, 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,
|
|
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, 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
|
|
}
|
|
w.Branding = branding
|
|
w.OnComplete = onComplete
|
|
w.Retention = retention
|
|
if webhookURL != nil {
|
|
w.WebhookURL = *webhookURL
|
|
}
|
|
if webhookSecret != nil {
|
|
w.WebhookSecret = *webhookSecret
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
|
|
var q string
|
|
var args []interface{}
|
|
if teamID != nil {
|
|
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
|
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, 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
|
|
}
|
|
w.Branding = branding
|
|
w.OnComplete = onComplete
|
|
w.Retention = retention
|
|
if webhookURL != nil {
|
|
w.WebhookURL = *webhookURL
|
|
}
|
|
if webhookSecret != nil {
|
|
w.WebhookSecret = *webhookSecret
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
|
|
// Build dynamic SET clause
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
idx := 1
|
|
|
|
add := func(col string, val interface{}) {
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
|
args = append(args, val)
|
|
idx++
|
|
}
|
|
|
|
if patch.Name != nil {
|
|
add("name", *patch.Name)
|
|
}
|
|
if patch.Description != nil {
|
|
add("description", *patch.Description)
|
|
}
|
|
if patch.Branding != nil {
|
|
add("branding", string(*patch.Branding))
|
|
}
|
|
if patch.EntryMode != nil {
|
|
add("entry_mode", *patch.EntryMode)
|
|
}
|
|
if patch.IsActive != nil {
|
|
add("is_active", *patch.IsActive)
|
|
}
|
|
if patch.OnComplete != nil {
|
|
add("on_complete", string(*patch.OnComplete))
|
|
}
|
|
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
|
|
}
|
|
|
|
// Increment version on any edit
|
|
sets = append(sets, fmt.Sprintf("version = version + 1"))
|
|
|
|
q := "UPDATE workflows SET "
|
|
for i, s := range sets {
|
|
if i > 0 {
|
|
q += ", "
|
|
}
|
|
q += s
|
|
}
|
|
q += fmt.Sprintf(" WHERE id = $%d", idx)
|
|
args = append(args, id)
|
|
|
|
_, err := DB.ExecContext(ctx, q, args...)
|
|
return err
|
|
}
|
|
|
|
func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
|
_, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
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, webhook_url, webhook_secret,
|
|
created_by, created_at, updated_at
|
|
FROM workflows WHERE team_id = $1
|
|
ORDER BY name ASC`, teamID)
|
|
}
|
|
|
|
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, webhook_url, webhook_secret,
|
|
created_by, created_at, updated_at
|
|
FROM workflows WHERE team_id IS NULL
|
|
ORDER BY name ASC`)
|
|
}
|
|
|
|
func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
|
|
rows, err := DB.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []models.Workflow
|
|
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, &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()
|
|
}
|
|
|
|
// ── Stages ──────────────────────────────────
|
|
|
|
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
|
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
|
stageConfig := jsonOrEmpty(st.StageConfig)
|
|
branchRules := jsonOrEmpty(st.BranchRules)
|
|
stageMode := st.StageMode
|
|
if stageMode == "" {
|
|
stageMode = models.StageModeForm
|
|
}
|
|
audience := st.Audience
|
|
if audience == "" {
|
|
audience = models.AudienceTeam
|
|
}
|
|
stageType := st.StageType
|
|
if stageType == "" {
|
|
stageType = models.StageTypeSimple
|
|
}
|
|
return DB.QueryRowContext(ctx, `
|
|
INSERT INTO workflow_stages (workflow_id, ordinal, name, assignment_team_id,
|
|
form_template, stage_mode, audience, stage_type,
|
|
auto_transition, stage_config, branch_rules, starlark_hook,
|
|
surface_pkg_id, sla_seconds)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id, created_at`,
|
|
st.WorkflowID, st.Ordinal, st.Name, st.AssignmentTeamID,
|
|
formTpl, stageMode, audience, stageType,
|
|
st.AutoTransition, stageConfig, branchRules, st.StarlarkHook,
|
|
st.SurfacePkgID, st.SLASeconds,
|
|
).Scan(&st.ID, &st.CreatedAt)
|
|
}
|
|
|
|
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, workflow_id, ordinal, name, assignment_team_id,
|
|
form_template, stage_mode, audience, stage_type,
|
|
auto_transition, stage_config, branch_rules, starlark_hook,
|
|
surface_pkg_id, sla_seconds, created_at
|
|
FROM workflow_stages WHERE workflow_id = $1
|
|
ORDER BY ordinal ASC`, workflowID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []models.WorkflowStage
|
|
for rows.Next() {
|
|
var st models.WorkflowStage
|
|
var formTpl, stageConfig, branchRules []byte
|
|
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
|
&st.AssignmentTeamID, &formTpl, &st.StageMode, &st.Audience, &st.StageType,
|
|
&st.AutoTransition, &stageConfig, &branchRules, &st.StarlarkHook,
|
|
&st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
st.FormTemplate = formTpl
|
|
st.StageConfig = stageConfig
|
|
st.BranchRules = branchRules
|
|
result = append(result, st)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
|
|
formTpl := jsonOrEmpty(st.FormTemplate)
|
|
stageConfig := jsonOrEmpty(st.StageConfig)
|
|
branchRules := jsonOrEmpty(st.BranchRules)
|
|
stageMode := st.StageMode
|
|
if stageMode == "" {
|
|
stageMode = models.StageModeForm
|
|
}
|
|
audience := st.Audience
|
|
if audience == "" {
|
|
audience = models.AudienceTeam
|
|
}
|
|
stageType := st.StageType
|
|
if stageType == "" {
|
|
stageType = models.StageTypeSimple
|
|
}
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE workflow_stages
|
|
SET ordinal = $2, name = $3, assignment_team_id = $4,
|
|
form_template = $5, stage_mode = $6, audience = $7, stage_type = $8,
|
|
auto_transition = $9, stage_config = $10, branch_rules = $11, starlark_hook = $12,
|
|
surface_pkg_id = $13, sla_seconds = $14
|
|
WHERE id = $1`,
|
|
st.ID, st.Ordinal, st.Name, st.AssignmentTeamID,
|
|
formTpl, stageMode, audience, stageType,
|
|
st.AutoTransition, stageConfig, branchRules, st.StarlarkHook,
|
|
st.SurfacePkgID, st.SLASeconds)
|
|
return err
|
|
}
|
|
|
|
func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
|
|
_, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
|
|
tx, err := DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for i, id := range orderedIDs {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workflow_stages SET ordinal = $1 WHERE id = $2 AND workflow_id = $3`,
|
|
i, id, workflowID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ── Versions ────────────────────────────────
|
|
|
|
func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
|
|
return DB.QueryRowContext(ctx, `
|
|
INSERT INTO workflow_versions (workflow_id, version_number, snapshot)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, created_at`,
|
|
v.WorkflowID, v.VersionNumber, string(v.Snapshot),
|
|
).Scan(&v.ID, &v.CreatedAt)
|
|
}
|
|
|
|
func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
|
|
v := &models.WorkflowVersion{}
|
|
var snapshot []byte
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT id, workflow_id, version_number, snapshot, created_at
|
|
FROM workflow_versions WHERE workflow_id = $1 AND version_number = $2`,
|
|
workflowID, versionNumber,
|
|
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v.Snapshot = snapshot
|
|
return v, nil
|
|
}
|
|
|
|
func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
|
|
v := &models.WorkflowVersion{}
|
|
var snapshot []byte
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT id, workflow_id, version_number, snapshot, created_at
|
|
FROM workflow_versions WHERE workflow_id = $1
|
|
ORDER BY version_number DESC LIMIT 1`,
|
|
workflowID,
|
|
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v.Snapshot = snapshot
|
|
return v, nil
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────
|
|
|
|
func jsonOrEmpty(b json.RawMessage) string {
|
|
if len(b) == 0 {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func jsonOrDefault(b json.RawMessage, def string) string {
|
|
if len(b) == 0 {
|
|
return def
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func jsonOrNull(b json.RawMessage) interface{} {
|
|
if len(b) == 0 || string(b) == "null" {
|
|
return nil
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func nullIfEmpty(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
|
|
|
|
// ── Lifecycle operations (v0.37.15) ──
|
|
|
|
// ── Review Comments (v0.35.0) ───────────────────────────
|