inquirex 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +203 -52
  4. data/docs/badges/coverage_badge.svg +2 -2
  5. data/examples/01_readme_example.rb +55 -0
  6. data/examples/02_readme_mermaid.png +0 -0
  7. data/examples/02_readme_mermaid.rb +36 -0
  8. data/examples/03_send_email_actions.rb +97 -0
  9. data/examples/README.md +33 -0
  10. data/justfile +3 -2
  11. data/lib/inquirex/accumulator.rb +29 -0
  12. data/lib/inquirex/actions/action.rb +68 -0
  13. data/lib/inquirex/actions/base.rb +41 -0
  14. data/lib/inquirex/actions/custom.rb +31 -0
  15. data/lib/inquirex/actions/outbox.rb +57 -0
  16. data/lib/inquirex/actions/runner.rb +52 -0
  17. data/lib/inquirex/actions/send_email.rb +174 -0
  18. data/lib/inquirex/actions/template.rb +95 -0
  19. data/lib/inquirex/actions/webhook.rb +139 -0
  20. data/lib/inquirex/actions.rb +57 -0
  21. data/lib/inquirex/answers.rb +13 -0
  22. data/lib/inquirex/completion_metadata.rb +82 -0
  23. data/lib/inquirex/definition.rb +67 -5
  24. data/lib/inquirex/dsl/action_builder.rb +53 -0
  25. data/lib/inquirex/dsl/flow_builder.rb +49 -6
  26. data/lib/inquirex/engine/state_serializer.rb +6 -4
  27. data/lib/inquirex/engine.rb +97 -5
  28. data/lib/inquirex/errors.rb +5 -0
  29. data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
  30. data/lib/inquirex/node.rb +2 -0
  31. data/lib/inquirex/rules/all.rb +12 -0
  32. data/lib/inquirex/rules/any.rb +11 -0
  33. data/lib/inquirex/rules/base.rb +6 -0
  34. data/lib/inquirex/rules/contains.rb +12 -0
  35. data/lib/inquirex/rules/equals.rb +12 -0
  36. data/lib/inquirex/rules/greater_than.rb +12 -0
  37. data/lib/inquirex/rules/less_than.rb +12 -0
  38. data/lib/inquirex/rules/not_empty.rb +12 -0
  39. data/lib/inquirex/validation/adapter.rb +1 -0
  40. data/lib/inquirex/validation/null_adapter.rb +5 -0
  41. data/lib/inquirex/version.rb +1 -1
  42. data/lib/inquirex/widget_registry.rb +1 -0
  43. data/lib/inquirex.rb +13 -0
  44. metadata +35 -5
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Inquirex
6
+ module Actions
7
+ # Declarative webhook effect: POSTs the completed answers as a JSON
8
+ # envelope ({"answers" => {...}}) to a statically-declared URL.
9
+ #
10
+ # Security posture — the URL must be auditable and its host covered by the
11
+ # definition's allowed_domains declaration:
12
+ #
13
+ # - the URL is a literal string; {{field}} templates are rejected so the
14
+ # destination host can never depend on user input
15
+ # - https only; plain http is permitted solely for localhost development
16
+ # - userinfo (https://user@host/) is rejected
17
+ # - redirects are not followed (Net::HTTP does not follow them)
18
+ # - the host check runs in Definition#validate!, which every definition
19
+ # passes through — including JSON-rehydrated ones, so a tampered URL
20
+ # fails at load time, before anything executes
21
+ #
22
+ # A non-2xx response raises Errors::ActionError, which the Runner records
23
+ # as a :failed result without blocking other actions.
24
+ class Webhook < Base
25
+ # Default open/read timeout in seconds for the webhook POST.
26
+ DEFAULT_TIMEOUT = 10
27
+ # Hosts for which plain http is tolerated (local development only).
28
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
29
+
30
+ attr_reader :url, :headers, :timeout
31
+
32
+ # @param url [String] literal https URL (no {{field}} templates)
33
+ # @param headers [Hash] extra request headers (literal values)
34
+ # @param timeout [Integer] open/read timeout in seconds
35
+ def initialize(url:, headers: {}, timeout: DEFAULT_TIMEOUT)
36
+ super()
37
+ @url = url.to_s
38
+ @headers = headers.transform_keys(&:to_s).transform_values(&:to_s).freeze
39
+ @timeout = timeout.to_i
40
+ @uri = parse_and_check!(@url)
41
+ freeze
42
+ end
43
+
44
+ # @return [String] lowercase host of the webhook URL
45
+ def host = @uri.host.downcase
46
+
47
+ # POSTs the answers envelope to the declared URL.
48
+ #
49
+ # @param answers [Answers] completed answers
50
+ # @param _outbox [Outbox] unused — webhooks build no messages
51
+ # @return [Net::HTTPResponse] the 2xx response
52
+ # @raise [Errors::ActionError] when the endpoint responds non-2xx
53
+ def call(answers, _outbox)
54
+ require "net/http"
55
+ response = post(answers)
56
+ code = response.code.to_i
57
+ return response if (200..299).cover?(code)
58
+
59
+ raise Errors::ActionError, "webhook #{host} responded with HTTP #{response.code}"
60
+ end
61
+
62
+ # Enforced from Definition#validate!.
63
+ #
64
+ # @param definition [Definition] owning definition, source of allowed_domains
65
+ # @return [void]
66
+ # @raise [Errors::DefinitionError] when the host is not allowlisted
67
+ def validate_against(definition)
68
+ return if definition.allowed_host?(host)
69
+
70
+ raise Errors::DefinitionError,
71
+ "webhook url host #{host.inspect} is not covered by allowed_domains " \
72
+ "#{definition.allowed_domains.inspect} — declare it at the top of the definition"
73
+ end
74
+
75
+ # @return [Hash] wire format, same shape .from_h accepts
76
+ def to_h
77
+ hash = { "type" => "webhook", "url" => @url }
78
+ hash["headers"] = @headers unless @headers.empty?
79
+ hash["timeout"] = @timeout unless @timeout == DEFAULT_TIMEOUT
80
+ hash
81
+ end
82
+
83
+ # @param hash [Hash] string or symbol keys
84
+ # @return [Webhook]
85
+ def self.from_h(hash)
86
+ fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
87
+ new(
88
+ url: fetch.call(:url),
89
+ headers: fetch.call(:headers) || {},
90
+ timeout: fetch.call(:timeout) || DEFAULT_TIMEOUT
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ def post(answers)
97
+ request = Net::HTTP::Post.new(@uri)
98
+ request["Content-Type"] = "application/json"
99
+ @headers.each { |name, value| request[name] = value }
100
+ request.body = JSON.generate("answers" => answers.to_h)
101
+
102
+ Net::HTTP.start(
103
+ @uri.host,
104
+ @uri.port,
105
+ use_ssl: @uri.scheme == "https",
106
+ open_timeout: @timeout,
107
+ read_timeout: @timeout
108
+ ) { |http| http.request(request) }
109
+ end
110
+
111
+ def parse_and_check!(url)
112
+ if url.include?("{{")
113
+ raise Errors::DefinitionError,
114
+ "webhook url does not support {{field}} templates — the destination must be static"
115
+ end
116
+
117
+ uri = parse(url)
118
+ raise Errors::DefinitionError, "webhook url must be http(s): #{url.inspect}" unless uri.is_a?(URI::HTTP)
119
+ raise Errors::DefinitionError, "webhook url must include a host: #{url.inspect}" if uri.host.to_s.empty?
120
+ raise Errors::DefinitionError, "webhook url must not include userinfo: #{url.inspect}" if uri.userinfo
121
+
122
+ if uri.scheme == "http" && !LOCAL_HOSTS.include?(uri.host.downcase)
123
+ raise Errors::DefinitionError,
124
+ "webhook url must use https (plain http is allowed only for localhost): #{url.inspect}"
125
+ end
126
+
127
+ uri
128
+ end
129
+
130
+ def parse(url)
131
+ URI.parse(url)
132
+ rescue URI::InvalidURIError => e
133
+ raise Errors::DefinitionError, "webhook url is not a valid URL: #{e.message}"
134
+ end
135
+ end
136
+
137
+ register(:webhook, Webhook)
138
+ end
139
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Post-completion actions: named side-effect declarations that run
5
+ # server-side after a flow finishes, with access to the collected answers.
6
+ #
7
+ # The DSL word `action` groups one or more *effects* (send_email, run, ...).
8
+ # Effects are looked up in a registry keyed by their DSL verb, so new effect
9
+ # types — a webhook, a save_record in inquirex-rails — plug in without core
10
+ # changes: register the class and it gains both the DSL word and JSON wire
11
+ # support.
12
+ #
13
+ # Inquirex::Actions.register(:webhook, MyGem::WebhookEffect)
14
+ #
15
+ # Actions never deliver anything themselves. send_email builds Mail::Message
16
+ # objects into Answers#outbox; the host application decides how to send them.
17
+ module Actions
18
+ @registry = {}
19
+
20
+ class << self
21
+ # Registers an effect class under a DSL verb name.
22
+ #
23
+ # @param type [Symbol] DSL verb (e.g. :send_email)
24
+ # @param klass [Class] an Actions::Base subclass
25
+ def register(type, klass)
26
+ @registry[type.to_sym] = klass
27
+ end
28
+
29
+ # @param type [Symbol, String]
30
+ # @return [Boolean]
31
+ def registered?(type)
32
+ @registry.key?(type.to_sym)
33
+ end
34
+
35
+ # @param type [Symbol, String]
36
+ # @return [Class]
37
+ # @raise [Errors::SerializationError] for unknown effect types
38
+ def lookup(type)
39
+ @registry.fetch(type.to_sym) do
40
+ raise Errors::SerializationError, "Unknown action effect type: #{type.inspect}"
41
+ end
42
+ end
43
+
44
+ # @return [Array<Symbol>] registered effect verbs
45
+ def types = @registry.keys
46
+
47
+ # Runs all of the definition's actions against the given answers.
48
+ #
49
+ # @param definition [Definition]
50
+ # @param answers [Answers, Hash]
51
+ # @return [Answers] with #outbox populated
52
+ def run(definition, answers)
53
+ Runner.new(definition).call(answers)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -84,10 +84,22 @@ module Inquirex
84
84
  end
85
85
 
86
86
  # Number of top-level answer keys.
87
+ #
88
+ # @return [Integer]
87
89
  def size
88
90
  @data.size
89
91
  end
90
92
 
93
+ # Mail::Message objects (and result trail) built by post-completion
94
+ # actions. Deliberately excluded from #to_h, #to_flat_h, #to_json and
95
+ # #== — the outbox rides alongside the answer data, never inside it.
96
+ # Delivery is the host application's responsibility.
97
+ #
98
+ # @return [Actions::Outbox]
99
+ def outbox
100
+ @outbox ||= Actions::Outbox.new
101
+ end
102
+
91
103
  # Merge another hash or Answers into this one (returns new Answers instance).
92
104
  #
93
105
  # @param other [Hash, Answers]
@@ -97,6 +109,7 @@ module Inquirex
97
109
  Answers.new(@data.merge(other_data))
98
110
  end
99
111
 
112
+ # @return [String] debug representation including the underlying hash
100
113
  def inspect
101
114
  "#<Inquirex::Answers #{@data.inspect}>"
102
115
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "json"
5
+
6
+ module Inquirex
7
+ # An OpenStruct describing how a flow reached completion: which rendering
8
+ # engine collected the answers, and any environment details that renderer
9
+ # chose to attach (host uname, user, IP addresses, terminal, ...).
10
+ #
11
+ # Only :engine and :engine_version are required members — enforced at
12
+ # construction. Everything else is free-form OpenStruct behavior: unset
13
+ # members read as nil, assignment creates members, nested OpenStructs
14
+ # (e.g. uname) are welcome.
15
+ #
16
+ # @example
17
+ # meta = Inquirex::CompletionMetadata.new(
18
+ # engine: "inquirex-tty", engine_version: "0.5.0",
19
+ # uname: OpenStruct.new(Etc.uname)
20
+ # )
21
+ # meta.engine # => "inquirex-tty"
22
+ # meta.uname.machine # => "arm64"
23
+ # meta.hostname # => nil (unset members read as nil)
24
+ # meta.to_h # => plain nested Hash, JSON-ready
25
+ class CompletionMetadata < OpenStruct
26
+ # Members that must be provided at construction; everything else is optional.
27
+ REQUIRED_MEMBERS = %i[engine engine_version].freeze
28
+
29
+ # Exists solely to make :engine and :engine_version required keywords —
30
+ # OpenStruct itself would accept anything.
31
+ #
32
+ # @param engine [String] rendering front-end name (e.g. "inquirex-tty")
33
+ # @param engine_version [String] rendering front-end version
34
+ # @param extra [Hash] any additional, optional members
35
+ def initialize(engine:, engine_version:, **extra) # rubocop:disable Lint/UselessMethodDefinition
36
+ super
37
+ end
38
+
39
+ # Rebuilds an instance from a hash (e.g. persisted engine state after a
40
+ # JSON round-trip). Keys may be strings or symbols; nested hashes come
41
+ # back as OpenStructs so dot-access survives the round-trip.
42
+ #
43
+ # @param hash [Hash, nil]
44
+ # @return [CompletionMetadata, nil] nil when hash is nil or empty
45
+ # @raise [ArgumentError] when :engine or :engine_version is missing
46
+ def self.from_h(hash)
47
+ return nil if hash.nil? || hash.empty?
48
+
49
+ members = hash.to_h { |k, v| [k.to_sym, v.is_a?(Hash) ? OpenStruct.new(v) : v] }
50
+ new(**members)
51
+ end
52
+
53
+ # OpenStruct#to_h is shallow; deep-convert nested OpenStructs (uname et
54
+ # al.) so state serialization and JSON output stay plain data.
55
+ #
56
+ # @return [Hash]
57
+ def to_h
58
+ deep_plain(super)
59
+ end
60
+
61
+ # @return [String] JSON representation
62
+ def to_json(*)
63
+ JSON.generate(to_h)
64
+ end
65
+
66
+ # @return [String] debug representation of the deep-plain member hash
67
+ def inspect
68
+ "#<Inquirex::CompletionMetadata #{to_h.inspect}>"
69
+ end
70
+
71
+ private
72
+
73
+ def deep_plain(value)
74
+ case value
75
+ when OpenStruct then deep_plain(value.to_h)
76
+ when Hash then value.transform_values { |v| deep_plain(v) }
77
+ when Array then value.map { |v| deep_plain(v) }
78
+ else value
79
+ end
80
+ end
81
+ end
82
+ end
@@ -13,7 +13,14 @@ module Inquirex
13
13
  # @attr_reader start_step_id [Symbol] id of the first step in the flow
14
14
  # @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
15
15
  class Definition
16
- attr_reader :id, :version, :meta, :start_step_id, :steps, :accumulators
16
+ attr_reader :id,
17
+ :version,
18
+ :meta,
19
+ :start_step_id,
20
+ :steps,
21
+ :accumulators,
22
+ :actions,
23
+ :allowed_domains
17
24
 
18
25
  # @param start_step_id [Symbol] id of the initial step
19
26
  # @param nodes [Hash<Symbol, Node>] all steps keyed by id
@@ -21,14 +28,20 @@ module Inquirex
21
28
  # @param version [String] semver
22
29
  # @param meta [Hash] frontend metadata
23
30
  # @param accumulators [Hash<Symbol, Accumulator>] named running totals
31
+ # @param actions [Array<Actions::Action>] post-completion actions, in order
32
+ # @param allowed_domains [Array<String>] hosts outbound effects (webhook)
33
+ # may send answers to; "example.com" exact, "*.example.com" subdomains
24
34
  # @raise [Errors::DefinitionError] if start_step_id is not present in nodes
25
- def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {}, accumulators: {})
35
+ def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
36
+ accumulators: {}, actions: [], allowed_domains: [])
26
37
  @id = id
27
38
  @version = version
28
39
  @meta = meta.freeze
29
40
  @start_step_id = start_step_id.to_sym
30
41
  @steps = nodes.freeze
31
42
  @accumulators = accumulators.freeze
43
+ @actions = actions.freeze
44
+ @allowed_domains = normalize_domains(allowed_domains)
32
45
  validate!
33
46
  freeze
34
47
  end
@@ -50,6 +63,25 @@ module Inquirex
50
63
  @steps.keys
51
64
  end
52
65
 
66
+ # Whether a host is covered by the allowed_domains declaration.
67
+ # "example.com" matches that host exactly; "*.example.com" matches any
68
+ # subdomain but not the apex. Matching is case-insensitive; an empty
69
+ # allowlist allows nothing.
70
+ #
71
+ # @example With allowed_domains ["*.example.com"]
72
+ # definition.allowed_host?("api.example.com") # => true
73
+ # definition.allowed_host?("API.EXAMPLE.COM") # => true (case-insensitive)
74
+ # definition.allowed_host?("example.com") # => false (wildcard excludes the apex)
75
+ #
76
+ # @param host [String]
77
+ # @return [Boolean]
78
+ def allowed_host?(host)
79
+ target = host.to_s.downcase
80
+ @allowed_domains.any? do |entry|
81
+ entry.start_with?("*.") ? target.end_with?(entry[1..]) : target == entry
82
+ end
83
+ end
84
+
53
85
  # Serializes the definition to a JSON string.
54
86
  # Lambdas (default procs, compute blocks) are silently stripped.
55
87
  #
@@ -66,6 +98,7 @@ module Inquirex
66
98
  hash["id"] = @id if @id
67
99
  hash["version"] = @version
68
100
  hash["meta"] = @meta unless @meta.empty?
101
+ hash["allowed_domains"] = @allowed_domains unless @allowed_domains.empty?
69
102
  hash["start"] = @start_step_id.to_s
70
103
  unless @accumulators.empty?
71
104
  hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
@@ -73,6 +106,8 @@ module Inquirex
73
106
  end
74
107
  end
75
108
  hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
109
+ serializable_actions = @actions.select(&:serializable?)
110
+ hash["actions"] = serializable_actions.map(&:to_h) unless serializable_actions.empty?
76
111
  hash
77
112
  end
78
113
 
@@ -97,6 +132,8 @@ module Inquirex
97
132
  start = hash["start"] || hash[:start]
98
133
  steps_data = hash["steps"] || hash[:steps] || {}
99
134
  acc_data = hash["accumulators"] || hash[:accumulators] || {}
135
+ actions_data = hash["actions"] || hash[:actions] || []
136
+ domains = hash["allowed_domains"] || hash[:allowed_domains] || []
100
137
 
101
138
  nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
102
139
  sym_id = step_id.to_sym
@@ -108,15 +145,40 @@ module Inquirex
108
145
  h[sym] = Accumulator.from_h(sym, entry)
109
146
  end
110
147
 
111
- new(start_step_id: start, nodes:, id:, version:, meta:, accumulators:)
148
+ actions = actions_data.map { |entry| Actions::Action.from_h(entry) }
149
+
150
+ new(start_step_id: start,
151
+ nodes:,
152
+ id:,
153
+ version:,
154
+ meta:,
155
+ accumulators:,
156
+ actions:,
157
+ allowed_domains: domains)
112
158
  end
113
159
 
114
160
  private
115
161
 
116
162
  def validate!
117
- return if @steps.key?(@start_step_id)
163
+ unless @steps.key?(@start_step_id)
164
+ raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
165
+ end
118
166
 
119
- raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
167
+ @actions.each do |action|
168
+ action.effects.each { |effect| effect.validate_against(self) }
169
+ end
170
+ end
171
+
172
+ def normalize_domains(domains)
173
+ domains.map do |entry|
174
+ domain = entry.to_s.strip.downcase
175
+ if domain.empty? || domain == "*" || domain.match?(%r{[/\s:@]})
176
+ raise Errors::DefinitionError,
177
+ "allowed_domains entries must be bare domains like \"example.com\" " \
178
+ "or \"*.example.com\", got #{entry.inspect}"
179
+ end
180
+ domain
181
+ end.freeze
120
182
  end
121
183
  end
122
184
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module DSL
5
+ # Builds an Actions::Action from an `action` DSL block. Every effect verb
6
+ # registered in Inquirex::Actions (send_email, plus anything host gems
7
+ # register) is available as a method automatically; `run` wraps arbitrary
8
+ # Ruby in an Actions::Custom effect.
9
+ #
10
+ # action :admin_alert do
11
+ # send_email to: "admin@example.com", subject: "New lead: {{name}}",
12
+ # html: "{{answers_summary}}"
13
+ # run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
14
+ # end
15
+ class ActionBuilder
16
+ def initialize
17
+ @effects = []
18
+ end
19
+
20
+ # Escape hatch: arbitrary server-side Ruby. Stripped from JSON.
21
+ #
22
+ # @yield [answers, outbox]
23
+ def run(&block)
24
+ raise Errors::DefinitionError, "run requires a block" unless block
25
+
26
+ @effects << Actions::Custom.new(block)
27
+ end
28
+
29
+ # Registered effect verbs (send_email, ...) resolve dynamically so that
30
+ # newly registered effect types become DSL words without core changes.
31
+ def method_missing(name, *args, **params, &)
32
+ return super unless Actions.registered?(name)
33
+
34
+ raise Errors::DefinitionError, "#{name} takes keyword arguments only" unless args.empty?
35
+
36
+ @effects << Actions.lookup(name).new(**params)
37
+ end
38
+
39
+ def respond_to_missing?(name, include_private = false)
40
+ Actions.registered?(name) || super
41
+ end
42
+
43
+ # @param id [Symbol]
44
+ # @param rule [Rules::Base, nil]
45
+ # @return [Actions::Action]
46
+ def build(id, rule: nil)
47
+ raise Errors::DefinitionError, "action #{id.inspect} declares no effects" if @effects.empty?
48
+
49
+ Actions::Action.new(id:, effects: @effects, rule:)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -16,6 +16,18 @@ module Inquirex
16
16
  @nodes = {}
17
17
  @meta = {}
18
18
  @accumulators = {}
19
+ @actions = []
20
+ @allowed_domains = []
21
+ end
22
+
23
+ # Declares the domains outbound effects (webhook) may send answers to.
24
+ # Conventionally the first declaration in a definition, so the flow's
25
+ # egress surface is auditable at a glance. "example.com" matches that
26
+ # host exactly; "*.example.com" matches its subdomains.
27
+ #
28
+ # @param domains [Array<String>]
29
+ def allowed_domains(*domains)
30
+ @allowed_domains.concat(domains.flatten)
19
31
  end
20
32
 
21
33
  # Declares a named running total the flow accumulates into as answers come in.
@@ -104,6 +116,35 @@ module Inquirex
104
116
  add_step(id, :confirm, &)
105
117
  end
106
118
 
119
+ # Declares a named post-completion action: effects (send_email, run, ...)
120
+ # executed server-side after the flow finishes, with the collected
121
+ # answers. Runs in declaration order; gate with a serializable rule via
122
+ # the if: option.
123
+ #
124
+ # @example Email the collected answers when business income was selected
125
+ # action :notify_sales, if: Rules::Contains.new(:income_types, "Business") do
126
+ # send_email to: "sales@example.com",
127
+ # subject: "New lead: {{name}}",
128
+ # html: "{{answers_summary}}"
129
+ # end
130
+ #
131
+ # @param id [Symbol] action identifier
132
+ # @param opts [Hash] only if: is recognized — a Rules::Base gate
133
+ # @yield block evaluated in ActionBuilder (send_email, run, ...)
134
+ def action(id, **opts, &block)
135
+ rule = opts.delete(:if)
136
+ raise Errors::DefinitionError, "Unknown action options: #{opts.keys.inspect}" unless opts.empty?
137
+
138
+ sym = id.to_sym
139
+ if @actions.any? { |a| a.id == sym }
140
+ raise Errors::DefinitionError, "Duplicate action id: #{sym.inspect}"
141
+ end
142
+
143
+ builder = ActionBuilder.new
144
+ builder.instance_eval(&block) if block
145
+ @actions << builder.build(sym, rule:)
146
+ end
147
+
107
148
  # Produces the frozen Definition.
108
149
  #
109
150
  # @return [Definition]
@@ -113,12 +154,14 @@ module Inquirex
113
154
  raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
114
155
 
115
156
  Definition.new(
116
- start_step_id: @start_step_id,
117
- nodes: @nodes,
118
- id: @flow_id,
119
- version: @flow_version,
120
- meta: @meta,
121
- accumulators: @accumulators
157
+ start_step_id: @start_step_id,
158
+ nodes: @nodes,
159
+ id: @flow_id,
160
+ version: @flow_version,
161
+ meta: @meta,
162
+ accumulators: @accumulators,
163
+ actions: @actions,
164
+ allowed_domains: @allowed_domains
122
165
  )
123
166
  end
124
167
 
@@ -5,11 +5,13 @@ module Inquirex
5
5
  # Handles state serialization for Engine persistence (DB, session store, etc.).
6
6
  # Normalizes string-keyed hashes (from JSON round-trips) to symbol-keyed hashes.
7
7
  module StateSerializer
8
+ # Per-key normalizers applied by .symbolize_state; unlisted keys pass through unchanged.
8
9
  SYMBOLIZERS = {
9
- current_step_id: ->(v) { v&.to_sym },
10
- history: ->(v) { Array(v).map { |e| e&.to_sym } },
11
- answers: ->(v) { symbolize_answers(v) },
12
- totals: ->(v) { symbolize_answers(v) }
10
+ current_step_id: ->(v) { v&.to_sym },
11
+ history: ->(v) { Array(v).map { |e| e&.to_sym } },
12
+ answers: ->(v) { symbolize_answers(v) },
13
+ totals: ->(v) { symbolize_answers(v) },
14
+ completion_metadata: ->(v) { symbolize_answers(v) }
13
15
  }.freeze
14
16
 
15
17
  # Normalizes a state hash so step ids and history entries are symbols.