Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,11 @@ Permissioning principle:
| SLA | Escalation/reminder notifications | Partial | Overdue visibility exists; automated escalation is next |
| Ownership | Discourse Assign integration | Planned | Target is step-entry assignment and auditable ownership changes |
| Operations | Bulk workflow transitions from list views | Missing | High-volume queue operation not yet first-class |
| Performance | Eliminate admin serialization N+1 queries | Planned | Preload workflow step/category associations in admin payloads for large workflow edit/list screens |
| Performance | Admin/list/chart/transition query-path N+1 and over-fetch hardening | Implemented | Admin serializers preloaded, workflow quick filters now SQL-scoped, chart loading scoped to selected workflow, transition lookup round-trips reduced, and workflow-state staleness indexing added |
| Performance | Bulk workflow arrival notification fan-out | Planned | Use bulk insert (`insert_all`) for category watcher notifications to reduce per-user insert overhead at high watcher counts |
| Performance | Cache workflow chart payloads | Planned | Add short-lived caching keyed by workflow and period to reduce repeated chart aggregation for frequent refreshes |
| Performance | Cache workflow visualisation payloads | Planned | Cache graph payloads keyed by topic/workflow-state version to avoid rebuilding identical visualisations |
| Performance | Production query-plan validation for workflow filters | Partial | Query shape is now SQL-driven; continue with `EXPLAIN`/index tuning against large production-like datasets |
| Reporting | Built-in workflow analytics dashboards | Partial | Data Explorer support exists; admin-native reporting is next |
| Lifecycle | Import/export/version workflow definitions | Missing | Useful for staging->production promotion and rollback |
| Integration | Event hooks / webhooks / automation integration | Planned | Transition and step events are good integration points |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ module Admin
class WorkflowStepOptionsController < ::Admin::AdminController
requires_plugin ::DiscourseWorkflow::PLUGIN_NAME

before_action :set_workflow_step, only: [:index, :new, :create]
before_action :set_workflow_step_option, only: [:show, :edit, :update, :destroy]
before_action :set_workflow_step, only: %i[index new create]
before_action :set_workflow_step_option, only: %i[show edit update destroy]

def index
if @workflow_step.present?
@workflow_step_options = WorkflowStepOption.where(workflow_step_id: @workflow_step.id).order(:position)
else
@workflow_step_options = WorkflowStepOption.all.order(:position)
end
@workflow_step_options =
if @workflow_step.present?
WorkflowStepOption.where(workflow_step_id: @workflow_step.id).order(:position).to_a
else
WorkflowStepOption.all.order(:position).to_a
end
ActiveRecord::Associations::Preloader.new(
records: @workflow_step_options,
associations: %i[workflow_option workflow_step],
).call
render_json_dump(
{ workflow_step_options:
ActiveModel::ArraySerializer.new(@workflow_step_options,
each_serializer: DiscourseWorkflow::WorkflowStepOptionSerializer)
})
{
workflow_step_options:
ActiveModel::ArraySerializer.new(
@workflow_step_options,
each_serializer: DiscourseWorkflow::WorkflowStepOptionSerializer,
),
},
)
end

def show
Expand All @@ -28,8 +37,9 @@ def new
workflow_step_option = WorkflowStepOption.new(workflow_step_option_params)
if workflow_step_option.save
render json: {
workflow_step_option: WorkflowStepOptionSerializer.new(workflow_step_option, root: false),
},
workflow_step_option:
WorkflowStepOptionSerializer.new(workflow_step_option, root: false),
},
status: :created
else
render_json_error workflow_step_option
Expand All @@ -39,7 +49,10 @@ def new
def create
workflow_step_option = WorkflowStepOption.new(workflow_step_option_params)
if !workflow_step_option.position.present?
if WorkflowStepOption.count == 0 || WorkflowStepOption.where(workflow_step_id: workflow_step_option.workflow_step_id).count == 0
if WorkflowStepOption.count == 0 ||
WorkflowStepOption.where(
workflow_step_id: workflow_step_option.workflow_step_id,
).count == 0
workflow_step_option.position = 1
else
workflow_step_option.position =
Expand All @@ -51,8 +64,9 @@ def create
end
if workflow_step_option.save
render json: {
workflow_step_option: WorkflowStepOptionSerializer.new(workflow_step_option, root: false),
},
workflow_step_option:
WorkflowStepOptionSerializer.new(workflow_step_option, root: false),
},
status: :created
else
render_json_error workflow_step_option
Expand All @@ -65,8 +79,9 @@ def edit
def update
if @workflow_step_option.update(workflow_step_option_params)
render json: {
workflow_step_option: WorkflowStepOptionSerializer.new(@workflow_step_option, root: false),
},
workflow_step_option:
WorkflowStepOptionSerializer.new(@workflow_step_option, root: false),
},
status: :ok
else
render_json_error @workflow_step_option
Expand Down Expand Up @@ -97,7 +112,12 @@ def set_workflow_step_option
end

def workflow_step_option_params
params.require(:workflow_step_option).permit(:position, :workflow_step_id, :workflow_option_id, :target_step_id, :other_attributes...)
params.require(:workflow_step_option).permit(
:position,
:workflow_step_id,
:workflow_option_id,
:target_step_id,
)
end

def ensure_admin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ module Admin
class WorkflowStepsController < ::Admin::AdminController
requires_plugin ::DiscourseWorkflow::PLUGIN_NAME

before_action :set_workflow, only: [:index, :new, :create]
before_action :set_workflow_step, only: [:show, :edit, :update, :destroy]
before_action :set_workflow, only: %i[index new create]
before_action :set_workflow_step, only: %i[show edit update destroy]

def index
if @workflow.present?
@workflow_steps = WorkflowStep.where(workflow_id: @workflow.id).order(:position)
else
@workflow_steps = WorkflowStep.all.order(:position)
end
@workflow_steps =
if @workflow.present?
WorkflowStep.where(workflow_id: @workflow.id).order(:position).to_a
else
WorkflowStep.all.order(:position).to_a
end
ActiveRecord::Associations::Preloader.new(
records: @workflow_steps,
associations: [:category, { workflow_step_options: :workflow_option }],
).call
render_json_dump(
{ workflow_steps:
ActiveModel::ArraySerializer.new(@workflow_steps,
each_serializer: DiscourseWorkflow::WorkflowStepSerializer)
})
{
workflow_steps:
ActiveModel::ArraySerializer.new(
@workflow_steps,
each_serializer: DiscourseWorkflow::WorkflowStepSerializer,
),
},
)
end

def show
Expand All @@ -28,8 +37,8 @@ def new
workflow_step = WorkflowStep.new(workflow_step_params)
if workflow_step.save
render json: {
workflow_step: WorkflowStepSerializer.new(workflow_step, root: false),
},
workflow_step: WorkflowStepSerializer.new(workflow_step, root: false),
},
status: :created
else
render_json_error workflow_step
Expand All @@ -39,16 +48,18 @@ def new
def create
workflow_step = WorkflowStep.new(workflow_step_params)
if !workflow_step.position.present?
if WorkflowStep.count == 0 || WorkflowStep.where(workflow_id: workflow_step.workflow_id).count == 0
if WorkflowStep.count == 0 ||
WorkflowStep.where(workflow_id: workflow_step.workflow_id).count == 0
workflow_step.position = 1
else
workflow_step.position = WorkflowStep.where(workflow_id: workflow_step.workflow_id).maximum(:position).to_i + 1
workflow_step.position =
WorkflowStep.where(workflow_id: workflow_step.workflow_id).maximum(:position).to_i + 1
end
end
if workflow_step.save
render json: {
workflow_step: WorkflowStepSerializer.new(workflow_step, root: false),
},
workflow_step: WorkflowStepSerializer.new(workflow_step, root: false),
},
status: :created
# redirect_to edit_workflow_workflow_step_path(workflow_id: workflow_step.workflow_id, id: workflow_step.id)
else
Expand All @@ -62,8 +73,8 @@ def edit
def update
if @workflow_step.update(workflow_step_params)
render json: {
workflow_step: WorkflowStepSerializer.new(@workflow_step, root: false),
},
workflow_step: WorkflowStepSerializer.new(@workflow_step, root: false),
},
status: :ok
else
render_json_error @workflow_step
Expand Down Expand Up @@ -94,19 +105,16 @@ def set_workflow_step
end

def workflow_step_params
params
.require(:workflow_step)
.permit(
:workflow_id,
:position,
:name,
:description,
:category_id,
:ai_enabled,
:ai_prompt,
:overdue_days,
:other_attributes...,
)
params.require(:workflow_step).permit(
:workflow_id,
:position,
:name,
:description,
:category_id,
:ai_enabled,
:ai_prompt,
:overdue_days,
)
end

def ensure_admin
Expand Down
24 changes: 17 additions & 7 deletions app/controllers/discourse_workflow/admin/workflows_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ class WorkflowsController < ::Admin::AdminController
before_action :find_workflow, only: %i[edit show update destroy]

def index
@workflows = Workflow.order(:enabled).order(:name).order(:id)
@workflows = Workflow.order(:enabled).order(:name).order(:id).to_a
ActiveRecord::Associations::Preloader.new(
records: @workflows,
associations: {
workflow_steps: [:category, { workflow_step_options: :workflow_option }],
},
).call
render_json_dump(
{ workflows:
ActiveModel::ArraySerializer.new(@workflows,
each_serializer: DiscourseWorkflow::WorkflowSerializer)
})
{
workflows:
ActiveModel::ArraySerializer.new(
@workflows,
each_serializer: DiscourseWorkflow::WorkflowSerializer,
),
},
)
end

def new
Expand All @@ -37,8 +47,8 @@ def create
def update
if @workflow.update(workflow_params)
render json: WorkflowSerializer.new(@workflow, root: false)
else
render_json_error @workflow
else
render_json_error @workflow
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ class WorkflowActionController < ApplicationController
def act
topic = Topic.find(params[:topic_id])
guardian.ensure_can_create_topic_on_category!(topic.category_id)
user_id = current_user.id
user = current_user
option = params[:option]
cooldown_key = nil
successful_transition = false

if user_id.present? && option.present? && topic.present?
cooldown_key = "discourse-workflow-transition-#{user_id}-#{topic.id}"
if user.present? && option.present? && topic.present?
cooldown_key = "discourse-workflow-transition-#{user.id}-#{topic.id}"
cooldown_acquired = Discourse.redis.set(cooldown_key, "1", ex: 5, nx: true)

if !cooldown_acquired
render json: failed_json
return
end

successful_transition = Transition.new.transition(user_id, topic, option)
successful_transition = Transition.new.transition(user, topic, option)
end

Discourse.redis.del(cooldown_key) if !successful_transition && cooldown_key.present?
Expand Down
32 changes: 20 additions & 12 deletions app/controllers/discourse_workflow/workflow_charts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ class WorkflowChartsController < ApplicationController

def index
weeks = normalized_weeks
workflows =
::DiscourseWorkflow::Workflow
.where(enabled: true)
.ordered
.includes(workflow_steps: { category: :parent_category })
selected_workflow = selected_workflow_for(workflows)
selected_workflow = selected_chart_workflow
date_range = chart_date_range(weeks)

render_json_dump(
Expand Down Expand Up @@ -55,9 +50,20 @@ def normalized_weeks
[requested, 12].min
end

def selected_workflow_for(workflows)
def selected_chart_workflow
workflow_scope = ::DiscourseWorkflow::Workflow.where(enabled: true).ordered
selected_id = params[:workflow_id].to_i
workflows.find { |workflow| workflow.id == selected_id } || workflows.first

if selected_id > 0
selected_workflow = load_chart_workflow(workflow_scope.where(id: selected_id))
return selected_workflow if selected_workflow.present?
end

load_chart_workflow(workflow_scope)
end

def load_chart_workflow(scope)
scope.includes(workflow_steps: { category: :parent_category }).first
end

def chart_date_range(weeks)
Expand All @@ -71,14 +77,13 @@ def build_series(workflow, date_range)

steps = workflow.workflow_steps.sort_by { |step| step.position.to_i }
return [] if steps.blank?
today = Date.current

step_ids = steps.map(&:id)
stats =
::DiscourseWorkflow::WorkflowStat
.where(workflow_id: workflow.id, workflow_step_id: step_ids)
.where(
cob_date: date_range.first.beginning_of_day..date_range.last.end_of_day,
)
.where(cob_date: date_range.first.beginning_of_day..date_range.last.end_of_day)
.group("DATE(cob_date)", :workflow_step_id)
.sum(:count)
counts_by_day_step =
Expand All @@ -93,7 +98,10 @@ def build_series(workflow, date_range)
step_name: step.name,
step_position: step.position.to_i,
color: category&.color || category&.parent_category&.color,
data: date_range.map { |date| counts_by_day_step.fetch([date, step.id], 0) },
data:
date_range.map do |date|
date > today ? nil : counts_by_day_step.fetch([date, step.id], 0)
end,
}
end
end
Expand Down
Loading