Files
Multica Eveandmultica-agent 0beaeddc0e MUL-8120 feat(workflows): owner-based workflow edit/delete permissions (#9388)
* MUL-8120 feat(workflows): record workflow owner and let owners edit their workflows

Any workspace member may now create a workflow and becomes its owner.
Editing, deleting and changing handoffs is open to the owner and to
workspace owners/admins. Default has no owner and stays admin-only.
Copying a workflow for one project now also requires managing that
project. Migration 585 adds issue_workflow.owner_id and backfills
existing named workflows with the workspace's earliest owner.

Co-authored-by: multica-agent <[email protected]>

* MUL-8120 feat(workflows): mirror owner-based workflow permissions in the UI

Members can create and duplicate workflows; the editor, column handoff
and flow panel are editable by the workflow's owner and admins. The
save-for-this-project option only shows to admins and the project lead.

Co-authored-by: multica-agent <[email protected]>

* MUL-8120 fix(workflows): only admins add statuses through workflow writes

Workflow create/update could add statuses to the shared workspace
catalog without a role check. Non-admins now get 403
status_create_forbidden when a step names a status that does not exist;
reusing a status by key or by name stays open.

Co-authored-by: multica-agent <[email protected]>

* MUL-8120 fix(workflows): hide new-status for members in the workflow editor

Members pick from existing statuses only. A template that needs statuses
the workspace lacks shows which ones and keeps Save disabled until they
are removed or an admin adds them.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-21 18:34:21 +08:00

659 lines
18 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: issue_workflow.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countProjectIssuesByStatus = `-- name: CountProjectIssuesByStatus :many
SELECT status, COUNT(*)::bigint AS issue_count
FROM issue
WHERE workspace_id = $1::uuid
AND project_id = ANY($2::uuid[])
GROUP BY status
ORDER BY status
`
type CountProjectIssuesByStatusParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectIds []pgtype.UUID `json:"project_ids"`
}
type CountProjectIssuesByStatusRow struct {
Status string `json:"status"`
IssueCount int64 `json:"issue_count"`
}
// Status census for a workflow switch or step removal, including terminal
// issues: every issue of the project must end on a status its workflow lists.
func (q *Queries) CountProjectIssuesByStatus(ctx context.Context, arg CountProjectIssuesByStatusParams) ([]CountProjectIssuesByStatusRow, error) {
rows, err := q.db.Query(ctx, countProjectIssuesByStatus, arg.WorkspaceID, arg.ProjectIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountProjectIssuesByStatusRow{}
for rows.Next() {
var i CountProjectIssuesByStatusRow
if err := rows.Scan(&i.Status, &i.IssueCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createDefaultIssueWorkflow = `-- name: CreateDefaultIssueWorkflow :many
INSERT INTO issue_workflow (workspace_id, name, description, initial_status_key, steps, is_default)
VALUES (
$1::uuid,
'Default',
'',
'todo',
$2::jsonb,
true
)
ON CONFLICT (workspace_id) WHERE is_default DO NOTHING
RETURNING id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id
`
type CreateDefaultIssueWorkflowParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Steps []byte `json:"steps"`
}
// Returns no row when another request created the Default first.
func (q *Queries) CreateDefaultIssueWorkflow(ctx context.Context, arg CreateDefaultIssueWorkflowParams) ([]IssueWorkflow, error) {
rows, err := q.db.Query(ctx, createDefaultIssueWorkflow, arg.WorkspaceID, arg.Steps)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueWorkflow{}
for rows.Next() {
var i IssueWorkflow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createIssueWorkflow = `-- name: CreateIssueWorkflow :one
INSERT INTO issue_workflow (workspace_id, name, description, initial_status_key, steps, owner_id)
VALUES (
$1::uuid,
$2::text,
$3::text,
$4::text,
$5::jsonb,
$6::uuid
)
RETURNING id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id
`
type CreateIssueWorkflowParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
InitialStatusKey string `json:"initial_status_key"`
Steps []byte `json:"steps"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) CreateIssueWorkflow(ctx context.Context, arg CreateIssueWorkflowParams) (IssueWorkflow, error) {
row := q.db.QueryRow(ctx, createIssueWorkflow,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.InitialStatusKey,
arg.Steps,
arg.OwnerID,
)
var i IssueWorkflow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
)
return i, err
}
const deleteIssueWorkflow = `-- name: DeleteIssueWorkflow :execrows
DELETE FROM issue_workflow
WHERE id = $1::uuid
AND workspace_id = $2::uuid
AND NOT is_default
`
type DeleteIssueWorkflowParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteIssueWorkflow(ctx context.Context, arg DeleteIssueWorkflowParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteIssueWorkflow, arg.ID, arg.WorkspaceID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteIssueWorkflowsForWorkspace = `-- name: DeleteIssueWorkflowsForWorkspace :exec
DELETE FROM issue_workflow WHERE workspace_id = $1::uuid
`
// No foreign keys by project rule, so workspace teardown cleans up here.
func (q *Queries) DeleteIssueWorkflowsForWorkspace(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssueWorkflowsForWorkspace, workspaceID)
return err
}
const getDefaultIssueWorkflow = `-- name: GetDefaultIssueWorkflow :one
SELECT id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id FROM issue_workflow
WHERE workspace_id = $1::uuid
AND is_default
`
func (q *Queries) GetDefaultIssueWorkflow(ctx context.Context, workspaceID pgtype.UUID) (IssueWorkflow, error) {
row := q.db.QueryRow(ctx, getDefaultIssueWorkflow, workspaceID)
var i IssueWorkflow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
)
return i, err
}
const getIssueWorkflow = `-- name: GetIssueWorkflow :one
SELECT id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id FROM issue_workflow
WHERE id = $1::uuid
AND workspace_id = $2::uuid
`
type GetIssueWorkflowParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetIssueWorkflow(ctx context.Context, arg GetIssueWorkflowParams) (IssueWorkflow, error) {
row := q.db.QueryRow(ctx, getIssueWorkflow, arg.ID, arg.WorkspaceID)
var i IssueWorkflow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
)
return i, err
}
const listIssueWorkflowNamesUsingStatusKey = `-- name: ListIssueWorkflowNamesUsingStatusKey :many
SELECT name FROM issue_workflow
WHERE workspace_id = $1::uuid
AND NOT is_default
AND steps @> jsonb_build_array(jsonb_build_object('status_key', $2::text))
ORDER BY lower(name)
`
type ListIssueWorkflowNamesUsingStatusKeyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
StatusKey string `json:"status_key"`
}
// Archive guard for the status catalog: a status a workflow still lists cannot
// be retired, or that workflow's projects would lose a column.
func (q *Queries) ListIssueWorkflowNamesUsingStatusKey(ctx context.Context, arg ListIssueWorkflowNamesUsingStatusKeyParams) ([]string, error) {
rows, err := q.db.Query(ctx, listIssueWorkflowNamesUsingStatusKey, arg.WorkspaceID, arg.StatusKey)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIssueWorkflows = `-- name: ListIssueWorkflows :many
SELECT id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id FROM issue_workflow
WHERE workspace_id = $1::uuid
ORDER BY is_default DESC, lower(name), created_at, id
`
// Project workflows (MUL-7420). A workflow selects and orders statuses from
// the workspace's issue_status catalog. Projects opt in through
// project.workflow_id; a project without one uses the workspace's Default
// workflow row. Each step also stores who an issue entering it is handed to.
func (q *Queries) ListIssueWorkflows(ctx context.Context, workspaceID pgtype.UUID) ([]IssueWorkflow, error) {
rows, err := q.db.Query(ctx, listIssueWorkflows, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueWorkflow{}
for rows.Next() {
var i IssueWorkflow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNamedIssueWorkflowSteps = `-- name: ListNamedIssueWorkflowSteps :many
SELECT steps FROM issue_workflow
WHERE workspace_id = $1::uuid
AND NOT is_default
`
// The steps of every workflow except Default, for the Default snapshot.
func (q *Queries) ListNamedIssueWorkflowSteps(ctx context.Context, workspaceID pgtype.UUID) ([][]byte, error) {
rows, err := q.db.Query(ctx, listNamedIssueWorkflowSteps, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := [][]byte{}
for rows.Next() {
var steps []byte
if err := rows.Scan(&steps); err != nil {
return nil, err
}
items = append(items, steps)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsUsingDefaultIssueWorkflow = `-- name: ListProjectsUsingDefaultIssueWorkflow :many
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date, workflow_id FROM project
WHERE workspace_id = $1::uuid
AND workflow_id IS NULL
ORDER BY created_at, id
`
func (q *Queries) ListProjectsUsingDefaultIssueWorkflow(ctx context.Context, workspaceID pgtype.UUID) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjectsUsingDefaultIssueWorkflow, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
&i.WorkflowID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsUsingIssueWorkflow = `-- name: ListProjectsUsingIssueWorkflow :many
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date, workflow_id FROM project
WHERE workspace_id = $1::uuid
AND workflow_id = $2::uuid
ORDER BY created_at, id
`
type ListProjectsUsingIssueWorkflowParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorkflowID pgtype.UUID `json:"workflow_id"`
}
func (q *Queries) ListProjectsUsingIssueWorkflow(ctx context.Context, arg ListProjectsUsingIssueWorkflowParams) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjectsUsingIssueWorkflow, arg.WorkspaceID, arg.WorkflowID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
&i.WorkflowID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockProjectForWorkflowChange = `-- name: LockProjectForWorkflowChange :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date, workflow_id FROM project
WHERE id = $1::uuid
AND workspace_id = $2::uuid
FOR UPDATE
`
type LockProjectForWorkflowChangeParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Serializes concurrent workflow switches of one project.
func (q *Queries) LockProjectForWorkflowChange(ctx context.Context, arg LockProjectForWorkflowChangeParams) (Project, error) {
row := q.db.QueryRow(ctx, lockProjectForWorkflowChange, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
&i.WorkflowID,
)
return i, err
}
const remapProjectIssueStatus = `-- name: RemapProjectIssueStatus :many
UPDATE issue AS i SET
status = $1::text,
duplicate_of_issue_id = NULL,
position = (
SELECT COALESCE(MIN(target.position), 0) - 1
FROM issue AS target
WHERE target.workspace_id = i.workspace_id
AND target.status = $1::text
),
revision = i.revision + 1,
last_activity_at = GREATEST(COALESCE(i.last_activity_at, i.updated_at), now()),
updated_at = now()
WHERE i.workspace_id = $2::uuid
AND i.project_id = ANY($3::uuid[])
AND i.status = $4::text
AND i.status <> $1::text
RETURNING i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.origin_type, i.origin_id, i.first_executed_at, i.start_date, i.metadata, i.stage, i.properties, i.revision, i.last_activity_at, i.triage_state, i.duplicate_of_issue_id
`
type RemapProjectIssueStatusParams struct {
ToStatus string `json:"to_status"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectIds []pgtype.UUID `json:"project_ids"`
FromStatus string `json:"from_status"`
}
// Moves every issue of the given projects from one status to another as part
// of an administrative workflow change. Mirrors UpdateIssueStatus: the issue
// is re-ranked to the top of its new column, the revision advances, and a
// duplicate mark only survives cancelled -> cancelled (never the case here,
// because from and to differ). It deliberately touches nothing else, so it
// never hands an issue off or starts a run.
func (q *Queries) RemapProjectIssueStatus(ctx context.Context, arg RemapProjectIssueStatusParams) ([]Issue, error) {
rows, err := q.db.Query(ctx, remapProjectIssueStatus,
arg.ToStatus,
arg.WorkspaceID,
arg.ProjectIds,
arg.FromStatus,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Issue{}
for rows.Next() {
var i Issue
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
&i.DuplicateOfIssueID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeStatusFromDefaultIssueWorkflow = `-- name: RemoveStatusFromDefaultIssueWorkflow :exec
UPDATE issue_workflow SET
steps = COALESCE((
SELECT jsonb_agg(step ORDER BY ord)
FROM jsonb_array_elements(steps) WITH ORDINALITY AS s(step, ord)
WHERE step->>'status_key' <> $1::text
), '[]'::jsonb),
updated_at = now()
WHERE workspace_id = $2::uuid
AND is_default
AND steps @> jsonb_build_array(jsonb_build_object('status_key', $1::text))
`
type RemoveStatusFromDefaultIssueWorkflowParams struct {
StatusKey string `json:"status_key"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// An archived status leaves Default with it; Default never blocks an archive.
func (q *Queries) RemoveStatusFromDefaultIssueWorkflow(ctx context.Context, arg RemoveStatusFromDefaultIssueWorkflowParams) error {
_, err := q.db.Exec(ctx, removeStatusFromDefaultIssueWorkflow, arg.StatusKey, arg.WorkspaceID)
return err
}
const setProjectWorkflow = `-- name: SetProjectWorkflow :one
UPDATE project SET
workflow_id = $1::uuid,
updated_at = now()
WHERE id = $2::uuid
AND workspace_id = $3::uuid
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date, workflow_id
`
type SetProjectWorkflowParams struct {
WorkflowID pgtype.UUID `json:"workflow_id"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) SetProjectWorkflow(ctx context.Context, arg SetProjectWorkflowParams) (Project, error) {
row := q.db.QueryRow(ctx, setProjectWorkflow, arg.WorkflowID, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
&i.WorkflowID,
)
return i, err
}
const updateIssueWorkflow = `-- name: UpdateIssueWorkflow :one
UPDATE issue_workflow SET
name = $1::text,
description = $2::text,
initial_status_key = $3::text,
steps = $4::jsonb,
updated_at = now()
WHERE id = $5::uuid
AND workspace_id = $6::uuid
RETURNING id, workspace_id, name, description, initial_status_key, steps, created_at, updated_at, is_default, owner_id
`
type UpdateIssueWorkflowParams struct {
Name string `json:"name"`
Description string `json:"description"`
InitialStatusKey string `json:"initial_status_key"`
Steps []byte `json:"steps"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) UpdateIssueWorkflow(ctx context.Context, arg UpdateIssueWorkflowParams) (IssueWorkflow, error) {
row := q.db.QueryRow(ctx, updateIssueWorkflow,
arg.Name,
arg.Description,
arg.InitialStatusKey,
arg.Steps,
arg.ID,
arg.WorkspaceID,
)
var i IssueWorkflow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.InitialStatusKey,
&i.Steps,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.OwnerID,
)
return i, err
}