Skip to content

Commit 0810c65

Browse files
authored
Reverse the association between application forms and cases (#71)
## Changes - ApplicationForm publishes Created event on create - Minor: Rename application form id in ApplicationFormSubmitted event payload from id to application_form_id - BusinessProcess now always starts when an ApplicationForm is created. We moved the case creation logic from ApplicationForm to the BusinessProcess. The business process now listens for ApplicationFormCreated events and creates the case and executes the start step when - Get rid of BusinessProcess.execute for now since business processes start when an application form is created and cannot be manually started (this means BusinessProcess currently doesn't properly implement the Step interface) - Decoupled executing of BusinessProcess from the Case class, so Case no longer has an execute_business_process method that's called after_create - Reversed the association between case and application form. Case now has an application_form_id, while ApplicationForm no longer has a case_id Migrations for test app (will need to do for demo too) - Add application_form_id to passport_cases and test_cases - Remove case_id from test_application_forms and passport_application_forms Misc - Fix the publish_event_with_payload rspec matcher - Replaces some `puts` lines with `Rails.logger.debug` - Order methods in BusinessProcess alphabetically within each visibility section ## Context Moving in a direction that decouples application form from case based on this conversation https://nava.slack.com/archives/C03G1SWD9H7/p1747252433304899 Note: This currently breaks a couple of things about BusinessProcess which we can address in a future PR: - BusinessProcess no longer implements execute, so it no longer can act as a step in another BusinessProcess (note that it doesn't seem like we had a test for this, or maybe we did and I got rid of it by accident) - Relatedly: BusinessProcess can only trigger on an ApplicationFormCreated event, not manually through execute or through any other start event. An upcoming PR should generalize the start functionality to allow different start events (e.g. see BPMN https://www.trisotech.com/bpmn-101-three-ways-a-process-starts/). We'll need to update BusinessProcess to allow multiple start events and to have a separate handler for start events.
1 parent e3ee008 commit 0810c65

18 files changed

Lines changed: 143 additions & 120 deletions

app/helpers/flex/event_manager.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def unsubscribe(subscription)
1717
end
1818

1919
def publish(event_key, payload = {})
20-
puts "Event Manager: Publishing event '#{event_key}' with payload: #{payload.inspect}"
20+
Rails.logger.debug "Event Manager: Publishing event '#{event_key}' with payload: #{payload.inspect}"
2121
ActiveSupport::Notifications.instrument(event_key, payload)
2222
end
2323
end

app/models/flex/application_form.rb

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,24 @@ class ApplicationForm < ApplicationRecord
44

55
include Flex::Attributes
66

7-
attribute :case_id, :string
8-
97
attribute :status, :integer, default: 0
108
protected attr_writer :status, :integer
119
enum :status, in_progress: 0, submitted: 1
1210

13-
before_create :create_case, unless: ->(application_form) { application_form.case_id? }
11+
after_create :publish_created
1412
before_update :prevent_changes_if_submitted, if: :was_submitted?
1513

1614
def submit_application
1715
puts "Submitting application with ID: #{id}"
1816
self[:status] = :submitted
1917
save!
20-
publish_event
18+
publish_submitted
2119
end
2220

2321
protected
2422

2523
def event_payload
26-
{ id: id }
24+
{ application_form_id: id }
2725
end
2826

2927
protected
@@ -48,8 +46,13 @@ def prevent_changes_if_submitted
4846
throw :abort
4947
end
5048

51-
def publish_event
52-
puts "Publishing event #{self.class.name}Submitted for application with ID: #{id}"
49+
def publish_created
50+
Rails.logger.debug "Publishing event #{self.class.name}Created for application with ID: #{id}"
51+
EventManager.publish("#{self.class.name}Created", self.event_payload)
52+
end
53+
54+
def publish_submitted
55+
Rails.logger.debug "Publishing event #{self.class.name}Submitted for application with ID: #{id}"
5356
EventManager.publish("#{self.class.name}Submitted", self.event_payload)
5457
end
5558
end

app/models/flex/business_process.rb

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,86 +9,75 @@ module Flex
99
# # Define steps - can be UserTask or SystemProcess
1010
# bp.step('collect_info',
1111
# Flex::UserTask.new("Collect Information", TaskCreationService))
12-
#
12+
#
1313
# bp.step('process_data',
1414
# Flex::SystemProcess.new("Process Data", ->(kase) {
1515
# DataProcessor.new(kase).process
1616
# }))
17-
#
17+
#
1818
# # Set the starting step
1919
# bp.start('collect_info')
20-
#
20+
#
2121
# # Define transitions between steps based on events
2222
# bp.transition('collect_info', 'form_submitted', 'process_data')
2323
# bp.transition('process_data', 'processing_complete', 'end')
2424
# end
2525
#
2626
# Steps can be either:
27-
# - UserTask: Tasks that require human interaction
28-
# - SystemProcess: Automated tasks that run without user intervention
27+
# - UserTask: Tasks that require human interaction, created through a TaskCreationService
28+
# - SystemProcess: Automated tasks that run without user intervention, defined with a callable
2929
#
3030
# The process automatically listens for events and transitions between steps
31-
# based on the defined transitions. When a step transitions to 'end',
32-
# the case is automatically closed.
31+
# based on the defined transitions. Events can be triggered by either user actions
32+
# or system processes. When a step transitions to 'end', the case is automatically closed.
33+
#
34+
# Event payloads must contain either case_id or application_form_id to identify the case.
3335
#
3436
# @see Flex::UserTask
3537
# @see Flex::SystemProcess
3638
#
3739
# Key Methods:
38-
# - execute(kase): Starts or resumes execution of the process for a case
3940
# - start_listening_for_events: Starts listening for events that trigger transitions
4041
# - stop_listening_for_events: Stops listening for events (useful for cleanup)
4142
#
4243
# Class Methods:
4344
# @method define(name, case_class)
44-
# Creates a new BusinessProcess definition
45+
# Creates a new BusinessProcess definition. Also automatically starts listening for events.
4546
# @param [Symbol] name The name of the business process
4647
# @param [Class] case_class The case class this process operates on
47-
# @yield [BusinessProcessBuilder] builder DSL for defining the process
48-
# @return [BusinessProcess] The configured business process
48+
# @yield [BusinessProcessBuilder] builder DSL for defining the process steps and transitions
49+
# @return [BusinessProcess] The configured and activated business process
4950
#
5051
# Instance Methods:
51-
# @method execute(kase)
52-
# Starts or resumes execution of the process for a case
53-
# @param [ApplicationRecord] kase The case to execute the process on
54-
#
5552
# @method start_listening_for_events
56-
# Starts listening for events that can trigger transitions
53+
# Starts listening for events that can trigger transitions. Called automatically by define.
5754
#
5855
# @method stop_listening_for_events
59-
# Stops listening for events, useful for cleanup in tests
56+
# Stops listening for events and cleans up subscriptions. Useful for cleanup in tests.
6057
#
6158
class BusinessProcess
6259
include Step
6360

64-
attr_accessor :name, :description, :steps, :start, :transitions, :case_class
61+
attr_accessor :name, :description, :steps, :transitions, :case_class
6562

66-
def initialize(name:, case_class:, description: "", steps: {}, start: "", transitions: {})
63+
def initialize(name:, case_class:, description: "", steps: {}, start_step_name: "", transitions: {})
6764
@subscriptions = {}
6865
@name = name
6966
@case_class = case_class
7067
@description = description
71-
@start = start
7268
@steps = steps
69+
@start_step_name = start_step_name
7370
@transitions = transitions
7471
@listening = false
7572
end
7673

77-
def execute(kase)
78-
if kase.business_process_current_step.blank?
79-
kase.business_process_current_step = @start
80-
end
81-
steps[start].execute(kase)
82-
kase.save!
83-
end
84-
8574
def start_listening_for_events
8675
if @listening
8776
Rails.logger.debug "Flex::BusinessProcess with name #{name} already listening for events"
8877
return
8978
end
9079

91-
get_event_names_from_transitions.each do |event_name|
80+
get_event_names.each do |event_name|
9281
Rails.logger.debug "Flex::BusinessProcess with name #{name} subscribing to event: #{event_name}"
9382
@subscriptions[event_name] = EventManager.subscribe(event_name, method(:handle_event))
9483
end
@@ -109,25 +98,70 @@ def stop_listening_for_events
10998

11099
private
111100

112-
def handle_event(event)
113-
Rails.logger.debug "Handling event: #{event[:name]} for case ID: #{event[:payload][:case_id]}"
114-
kase = @case_class.find(event[:payload][:case_id])
115-
current_step = kase.business_process_current_step
116-
next_step = @transitions&.dig(current_step, event[:name])
117-
Rails.logger.debug "Current step: #{current_step}, Next step: #{next_step}"
118-
return unless next_step # Skip processing if no valid transition exists
101+
def create_case_from_event(event)
102+
Rails.logger.debug "Creating case from event: #{event[:name]} with payload: #{event[:payload]}"
103+
raise "Cannot create case from event #{event[:name]}. Event must be an ApplicationFormCreated event" unless event[:name].end_with?("ApplicationFormCreated")
104+
kase = @case_class.create!(
105+
application_form_id: event[:payload][:application_form_id],
106+
business_process_current_step: @start_step_name
107+
)
108+
kase
109+
end
119110

120-
kase.business_process_current_step = next_step
121-
kase.save!
122-
if next_step == "end"
111+
def execute_current_step(kase)
112+
step_name = kase.business_process_current_step
113+
Rails.logger.debug "Executing current step: #{step_name} for case ID: #{kase.id}"
114+
if step_name == "end"
123115
kase.close
124116
else
125-
@steps[next_step].execute(kase)
117+
@steps[step_name].execute(kase)
118+
end
119+
end
120+
121+
def get_case_from_event(event)
122+
Rails.logger.debug "Getting case from event: #{event[:name]} with payload: #{event[:payload]}"
123+
if event[:payload].key?(:application_form_id)
124+
Rails.logger.debug "Getting case from event payload with application_form_id"
125+
@case_class.find_by(application_form_id: event[:payload][:application_form_id])
126+
else
127+
Rails.logger.debug "Getting case from event payload with case_id"
128+
@case_class.find(event[:payload][:case_id])
126129
end
127130
end
128131

129-
def get_event_names_from_transitions
130-
@transitions.values.flat_map(&:keys).uniq
132+
def get_event_names
133+
@transitions.values.flat_map(&:keys).uniq | [ start_event_name ]
134+
end
135+
136+
def get_next_step(kase, event_name)
137+
current_step = kase.business_process_current_step
138+
next_step = @transitions&.dig(current_step, event_name)
139+
next_step
140+
end
141+
142+
def handle_event(event)
143+
Rails.logger.debug "Handling event: #{event[:name]} with payload: #{event[:payload]}"
144+
if start_event?(event[:name])
145+
kase = create_case_from_event(event)
146+
else
147+
kase = get_case_from_event(event)
148+
next_step = get_next_step(kase, event[:name])
149+
return unless next_step
150+
151+
Rails.logger.debug "Transitioning to step #{next_step} and executing the step"
152+
kase.business_process_current_step = next_step
153+
kase.save!
154+
end
155+
156+
execute_current_step(kase)
157+
end
158+
159+
def start_event?(event_name)
160+
event_name == start_event_name
161+
end
162+
163+
def start_event_name
164+
@case_class.name.sub("Case", "ApplicationFormCreated")
131165
end
132166

133167
class << self

app/models/flex/business_process_builder.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def initialize(name, case_class)
1111
end
1212

1313
def start(step_name)
14-
@start = step_name
14+
@start_step_name = step_name
1515
end
1616

1717
def step(name, step)
@@ -29,7 +29,7 @@ def build
2929
case_class: @case_class,
3030
description: "",
3131
steps: @steps,
32-
start: @start,
32+
start_step_name: @start_step_name,
3333
transitions: @transitions
3434
)
3535
end

app/models/flex/case.rb

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ module Flex
22
class Case < ApplicationRecord
33
self.abstract_class = true
44

5+
attribute :application_form_id, :string
6+
57
attribute :status, :integer, default: 0
68
protected attr_writer :status, :integer
79
enum :status, open: 0, closed: 1
@@ -10,8 +12,6 @@ class Case < ApplicationRecord
1012

1113
protected attr_accessor :business_process
1214

13-
after_create :execute_business_process
14-
1515
def close
1616
self[:status] = :closed
1717
save
@@ -21,11 +21,5 @@ def reopen
2121
self[:status] = :open
2222
save
2323
end
24-
25-
protected
26-
27-
def execute_business_process
28-
business_process.execute(self)
29-
end
3024
end
3125
end

spec/acceptance/flex/passport_application_case_acceptance_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
require 'rails_helper'
22

33
module Flex
4-
RSpec.describe PassportApplicationForm, type: :model do
5-
let(:test_form) { described_class.new }
4+
RSpec.describe PassportBusinessProcess, type: :model do
5+
let(:test_form) { PassportApplicationForm.new }
66

77
it "creates a passport case upon starting a passport application form and properly progresses through steps" do
88
# create new application
99
test_form.save!
1010

1111
# check case created and open with correct current step
12-
kase = PassportCase.find(test_form.case_id)
12+
kase = PassportCase.find_by_application_form_id(test_form.id)
1313
expect(kase).not_to be_nil
1414
expect(kase.status).to eq ("open")
1515
expect(kase.business_process_current_step).to eq ("collect_application_info")

spec/dummy/app/models/passport_application_form.rb

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@ class PassportApplicationForm < Flex::ApplicationForm
66

77
flex_attribute :date_of_birth, :memorable_date
88

9-
attribute :case_id, :integer
10-
private def case_id=(value)
11-
self[:case_id] = value
12-
end
13-
149
def has_all_necessary_fields?
1510
!first_name.nil? && !last_name.nil? && !date_of_birth.nil?
1611
end
@@ -22,17 +17,4 @@ def submit_application
2217
def full_name
2318
"#{first_name} #{last_name}"
2419
end
25-
26-
protected
27-
28-
def event_payload
29-
parent_payload = super
30-
parent_payload.merge({ case_id: case_id })
31-
end
32-
33-
private
34-
35-
def has_case_id?
36-
!case_id.nil?
37-
end
3820
end

spec/dummy/config/environments/test.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,8 @@
6161

6262
# Raise error when a before_action's only/except options reference missing actions
6363
config.action_controller.raise_on_missing_callback_actions = true
64+
65+
# Set log level to debug
66+
# config.log_level = :debug
67+
# config.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
6468
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddTestApplicationFormRefToTestCases < ActiveRecord::Migration[8.0]
2+
def change
3+
add_reference :test_cases, :application_form, type: :string
4+
end
5+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AddPassportApplicationFormRefToPassportCases < ActiveRecord::Migration[8.0]
2+
def change
3+
execute "DELETE FROM passport_cases"
4+
add_reference :passport_cases, :application_form, type: :string
5+
end
6+
end

0 commit comments

Comments
 (0)