signoff 0.1.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.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # The immutable audit record written for every workflow transition.
5
+ #
6
+ # Columns: workflowable (polymorphic), user_id, action, from_state, to_state,
7
+ # comment, metadata (jsonb), ip_address, user_agent, created_at.
8
+ class Event < ActiveRecord::Base
9
+ self.table_name = Signoff.configuration.event_table_name
10
+
11
+ # Explicitly required (independent of the host's
12
+ # +belongs_to_required_by_default+ setting) so every audit row is anchored
13
+ # to its subject.
14
+ belongs_to :workflowable, polymorphic: true,
15
+ inverse_of: :signoff_events,
16
+ optional: false
17
+ belongs_to :user,
18
+ class_name: (Signoff.configuration.user_class || "User").to_s,
19
+ optional: true
20
+
21
+ validates :action, presence: true
22
+ validates :to_state, presence: true
23
+
24
+ scope :chronological, -> { order(created_at: :asc, id: :asc) }
25
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
26
+ scope :with_action, ->(action) { where(action: action.to_s) }
27
+ scope :approvals, -> { with_action("approve") }
28
+ scope :rejections, -> { with_action("reject") }
29
+
30
+ # Audit rows are append-only. Once persisted they cannot be modified or
31
+ # destroyed through ActiveRecord unless +config.immutable_events+ is false.
32
+ def readonly?
33
+ return super unless Signoff.configuration.immutable_events
34
+
35
+ persisted? || super
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ Description:
2
+ Installs Signoff. Creates the immutable audit events table
3
+ migration (PostgreSQL JSONB) and a configuration initializer.
4
+
5
+ Example:
6
+ rails generate signoff:install
7
+
8
+ This will create:
9
+ config/initializers/signoff.rb
10
+ db/migrate/XXXXXXXXXXXXXX_create_signoff_events.rb
11
+
12
+ Options:
13
+ --table-name=signoff_events
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Signoff
7
+ module Generators
8
+ # Generates the events table migration and the configuration initializer.
9
+ #
10
+ # rails generate signoff:install
11
+ class InstallGenerator < Rails::Generators::Base
12
+ include ActiveRecord::Generators::Migration
13
+
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ desc "Creates the signoff events migration and initializer."
17
+
18
+ class_option :table_name, type: :string,
19
+ default: "signoff_events",
20
+ desc: "Name of the events table"
21
+ class_option :skip_metadata_index, type: :boolean, default: false,
22
+ desc: "Skip the GIN index on the metadata column"
23
+
24
+ def create_initializer
25
+ template "initializer.rb", "config/initializers/signoff.rb"
26
+ end
27
+
28
+ def create_migration_file
29
+ migration_template(
30
+ "migration.rb.tt",
31
+ File.join(db_migrate_path, "create_signoff_events.rb")
32
+ )
33
+ end
34
+
35
+ def display_post_install_message
36
+ say ""
37
+ say "signoff installed!", :green
38
+ say " 1. Review config/initializers/signoff.rb"
39
+ say " 2. Run: rails db:migrate"
40
+ say " 3. Add a state column to each workflow model, e.g.:"
41
+ say " rails g signoff:model ExpenseReport"
42
+ say ""
43
+ end
44
+
45
+ private
46
+
47
+ def table_name
48
+ options[:table_name]
49
+ end
50
+
51
+ def migration_version
52
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ Signoff.configure do |config|
4
+ # The model that represents the acting user (referenced by Event#user).
5
+ config.user_class = "User"
6
+
7
+ # Persist the request IP address on each event. Populate
8
+ # Signoff::Current.ip_address (e.g. by including
9
+ # Signoff::Controller in ApplicationController) for this to take
10
+ # effect.
11
+ config.track_ip_addresses = false
12
+
13
+ # Persist the request User-Agent on each event.
14
+ config.store_user_agent = false
15
+
16
+ # Default column used to store the current state on workflowable models.
17
+ # Override per-model with `signoff(column: :workflow_state)`.
18
+ config.default_state_column = :approval_state
19
+
20
+ # Validate the whole record before writing the state column on a transition.
21
+ # Leave false so unrelated validation errors never block a transition.
22
+ config.validate_on_transition = false
23
+
24
+ # :dependent strategy for the events association. Audit rows are immutable by
25
+ # default, so use :delete_all or :nullify (not :destroy).
26
+ config.dependent = :delete_all
27
+
28
+ # Set to false to allow events to be updated/destroyed through ActiveRecord.
29
+ # config.immutable_events = true
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSignoffEvents < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :<%= table_name %> do |t|
6
+ t.string :workflowable_type, null: false
7
+ t.bigint :workflowable_id, null: false
8
+ t.bigint :user_id
9
+ t.string :action, null: false
10
+ t.string :from_state
11
+ t.string :to_state, null: false
12
+ t.text :comment
13
+ t.jsonb :metadata, null: false, default: {}
14
+ t.string :ip_address
15
+ t.string :user_agent
16
+ t.datetime :created_at, null: false
17
+ end
18
+
19
+ add_index :<%= table_name %>, :workflowable_type
20
+ add_index :<%= table_name %>, :workflowable_id
21
+ add_index :<%= table_name %>, :created_at
22
+ add_index :<%= table_name %>, %i[workflowable_type workflowable_id created_at],
23
+ name: "idx_signoff_events_on_workflowable_and_created_at"
24
+ add_index :<%= table_name %>, %i[workflowable_type workflowable_id action created_at],
25
+ name: "idx_signoff_events_on_workflowable_and_action"
26
+ add_index :<%= table_name %>, :user_id
27
+ <%- unless options[:skip_metadata_index] -%>
28
+ add_index :<%= table_name %>, :metadata, using: :gin,
29
+ name: "idx_signoff_events_on_metadata"
30
+ <%- end -%>
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ Description:
2
+ Adds the signoff state column (default: approval_state) to a
3
+ model's table, with a sensible default and an index for fast state scopes.
4
+
5
+ Example:
6
+ rails generate signoff:model ExpenseReport
7
+
8
+ rails generate signoff:model Invoice --column=workflow_state --initial=pending
9
+
10
+ This will create:
11
+ db/migrate/XXXXXXXXXXXXXX_add_approval_state_to_expense_reports.rb
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Signoff
7
+ module Generators
8
+ # Adds the workflow state column to a model's table.
9
+ #
10
+ # rails generate signoff:model ExpenseReport
11
+ # rails generate signoff:model Invoice --column=workflow_state --initial=pending
12
+ class ModelGenerator < Rails::Generators::NamedBase
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Adds the signoff state column to a model's table."
18
+
19
+ class_option :column, type: :string, default: "approval_state",
20
+ desc: "Name of the state column to add"
21
+ class_option :initial, type: :string, default: "draft",
22
+ desc: "Default value for the state column"
23
+
24
+ def create_migration_file
25
+ migration_template(
26
+ "migration.rb.tt",
27
+ File.join(db_migrate_path, "add_#{column_name}_to_#{table_name}.rb")
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def column_name
34
+ options[:column]
35
+ end
36
+
37
+ def initial_state
38
+ options[:initial]
39
+ end
40
+
41
+ def migration_class_name
42
+ "Add#{column_name.camelize}To#{table_name.camelize}"
43
+ end
44
+
45
+ def migration_version
46
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ add_column :<%= table_name %>, :<%= column_name %>, :string,
6
+ null: false, default: <%= initial_state.inspect %>
7
+ add_index :<%= table_name %>, :<%= column_name %>
8
+ end
9
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # Global, mutable configuration for the gem. Access via
5
+ # +Signoff.configuration+ or set it inside an initializer:
6
+ #
7
+ # Signoff.configure do |config|
8
+ # config.user_class = "User"
9
+ # config.track_ip_addresses = true
10
+ # config.store_user_agent = true
11
+ # end
12
+ class Configuration
13
+ # String name of the model that represents the acting user. Resolved
14
+ # lazily so the constant does not have to be loaded at boot.
15
+ attr_accessor :user_class
16
+
17
+ # When true, the +ip_address+ captured in Signoff::Current (or
18
+ # passed explicitly) is persisted on every event.
19
+ attr_accessor :track_ip_addresses
20
+
21
+ # When true, the +user_agent+ captured in Signoff::Current (or
22
+ # passed explicitly) is persisted on every event.
23
+ attr_accessor :store_user_agent
24
+
25
+ # Default column used to store the current state on workflowable models.
26
+ # Overridable per-model via +signoff(column: :foo)+.
27
+ attr_accessor :default_state_column
28
+
29
+ # Physical table name backing Signoff::Event.
30
+ attr_accessor :event_table_name
31
+
32
+ # When true the workflowable record is fully validated before its state
33
+ # column is written. When false (the default) only the state column is
34
+ # updated, so unrelated validation errors never block a transition.
35
+ attr_accessor :validate_on_transition
36
+
37
+ # +:dependent+ strategy for the events association. Because audit rows are
38
+ # immutable by default, use an SQL-level strategy (+:delete_all+ or
39
+ # +:nullify+) rather than +:destroy+, which instantiates and would be
40
+ # blocked by the read-only guard.
41
+ attr_accessor :dependent
42
+
43
+ # When true (the default) persisted events are read-only: they cannot be
44
+ # updated or destroyed through ActiveRecord, guaranteeing an immutable
45
+ # audit trail. Set to false to allow +:destroy+ as a dependent strategy.
46
+ attr_accessor :immutable_events
47
+
48
+ def initialize
49
+ @user_class = "User"
50
+ @track_ip_addresses = false
51
+ @store_user_agent = false
52
+ @default_state_column = :approval_state
53
+ @event_table_name = "signoff_events"
54
+ @validate_on_transition = false
55
+ @dependent = :delete_all
56
+ @immutable_events = true
57
+ end
58
+
59
+ # Resolve the configured user class constant. Returns nil when the constant
60
+ # cannot be loaded (e.g. in a library context with no User model).
61
+ def user_model
62
+ return nil if user_class.nil?
63
+
64
+ user_class.to_s.constantize
65
+ rescue NameError
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # Optional controller mix-in that populates Signoff::Current from the
5
+ # request so transitions are attributed automatically:
6
+ #
7
+ # class ApplicationController < ActionController::Base
8
+ # include Signoff::Controller
9
+ # end
10
+ #
11
+ # It relies on a +current_user+ helper if one is available and respects the
12
+ # +track_ip_addresses+ / +store_user_agent+ configuration flags.
13
+ module Controller
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ before_action :set_signoff_context
18
+ end
19
+
20
+ private
21
+
22
+ def set_signoff_context
23
+ configuration = Signoff.configuration
24
+
25
+ Signoff::Current.user = current_user if respond_to?(:current_user, true)
26
+
27
+ return unless respond_to?(:request) && request
28
+
29
+ Signoff::Current.ip_address = request.remote_ip if configuration.track_ip_addresses
30
+ return unless configuration.store_user_agent
31
+
32
+ Signoff::Current.user_agent = request.user_agent
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/current_attributes"
4
+
5
+ module Signoff
6
+ # Request-scoped context used to attribute transitions when the acting user,
7
+ # IP address or user agent are not passed explicitly to a transition method.
8
+ #
9
+ # Populate it from a controller (see Signoff::Controller) or
10
+ # manually:
11
+ #
12
+ # Signoff::Current.set(user: current_user, ip_address: request.remote_ip) do
13
+ # report.approve!
14
+ # end
15
+ class Current < ActiveSupport::CurrentAttributes
16
+ attribute :user, :ip_address, :user_agent
17
+ end
18
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # Immutable-once-validated description of a model's workflow: its states, the
5
+ # allowed forward transitions, the reject state, authorization guards and
6
+ # lifecycle callbacks. Built by Signoff::DSL and stored on the model
7
+ # class as +signoff_definition+.
8
+ class Definition
9
+ attr_reader :states, :transitions, :authorizations,
10
+ :before_callbacks, :after_callbacks, :state_column
11
+ attr_writer :initial_state
12
+
13
+ def initialize(state_column: :approval_state)
14
+ @states = []
15
+ @transitions = {} # from(Symbol) => [to(Symbol), ...]
16
+ @authorizations = {} # from(Symbol) => callable guard
17
+ @before_callbacks = []
18
+ @after_callbacks = []
19
+ @initial_state = nil
20
+ @reject_state = nil
21
+ @state_column = state_column.to_sym
22
+ end
23
+
24
+ # --- builders (called by the DSL) ------------------------------------
25
+
26
+ def add_state(name)
27
+ name = name.to_sym
28
+ raise DefinitionError, "duplicate state #{name.inspect}" if @states.include?(name)
29
+
30
+ @states << name
31
+ end
32
+
33
+ def add_transition(from, to)
34
+ from = from.to_sym
35
+ Array(to).each do |target|
36
+ target = target.to_sym
37
+ @transitions[from] ||= []
38
+ @transitions[from] << target unless @transitions[from].include?(target)
39
+ end
40
+ end
41
+
42
+ def add_authorization(from, guard)
43
+ @authorizations[from.to_sym] = guard
44
+ end
45
+
46
+ def reject_state=(name)
47
+ @reject_state = name&.to_sym
48
+ end
49
+
50
+ attr_reader :reject_state
51
+
52
+ # --- queries ---------------------------------------------------------
53
+
54
+ def initial_state
55
+ @initial_state || @states.first
56
+ end
57
+
58
+ # Targets reachable from +state+ via a declared forward transition.
59
+ def forward_targets(state)
60
+ @transitions[state.to_sym] || []
61
+ end
62
+
63
+ # States with no outgoing forward transition.
64
+ def terminal_states
65
+ @states.reject { |s| forward_targets(s).any? }
66
+ end
67
+
68
+ # Terminal states that represent successful completion (everything that is
69
+ # terminal except the reject state).
70
+ def approval_terminal_states
71
+ terminal_states - [reject_state].compact
72
+ end
73
+
74
+ def guard_for(state)
75
+ @authorizations[state.to_sym]
76
+ end
77
+
78
+ # Resolve the next state when advancing forward from +from+. +to+ may be
79
+ # supplied to disambiguate when several forward transitions exist.
80
+ def next_state(from, to: nil)
81
+ from = from.to_sym
82
+ targets = forward_targets(from)
83
+
84
+ if to
85
+ to = to.to_sym
86
+ return to if targets.include?(to)
87
+
88
+ raise InvalidTransitionError,
89
+ "no transition declared from #{from.inspect} to #{to.inspect}"
90
+ end
91
+
92
+ case targets.size
93
+ when 0
94
+ raise InvalidTransitionError,
95
+ "no forward transition declared from #{from.inspect}"
96
+ when 1
97
+ targets.first
98
+ else
99
+ raise InvalidTransitionError,
100
+ "ambiguous transition from #{from.inspect}; pass to: " \
101
+ "(one of #{targets.inspect})"
102
+ end
103
+ end
104
+
105
+ # --- validation ------------------------------------------------------
106
+
107
+ def validate!
108
+ raise DefinitionError, "no states have been defined" if @states.empty?
109
+
110
+ validate_initial_state!
111
+ validate_transitions!
112
+ validate_reject_state!
113
+ validate_authorizations!
114
+ self
115
+ end
116
+
117
+ private
118
+
119
+ def validate_initial_state!
120
+ raise DefinitionError, "an initial state is required" if initial_state.nil?
121
+ return if @states.include?(initial_state)
122
+
123
+ raise DefinitionError,
124
+ "initial state #{initial_state.inspect} is not a declared state"
125
+ end
126
+
127
+ def validate_transitions!
128
+ @transitions.each do |from, targets|
129
+ unless @states.include?(from)
130
+ raise DefinitionError,
131
+ "transition from undeclared state #{from.inspect}"
132
+ end
133
+
134
+ targets.each do |target|
135
+ next if @states.include?(target)
136
+
137
+ raise DefinitionError,
138
+ "transition to undeclared state #{target.inspect}"
139
+ end
140
+ end
141
+ end
142
+
143
+ def validate_reject_state!
144
+ return if reject_state.nil? || @states.include?(reject_state)
145
+
146
+ raise DefinitionError,
147
+ "reject state #{reject_state.inspect} is not a declared state"
148
+ end
149
+
150
+ def validate_authorizations!
151
+ @authorizations.each_key do |state|
152
+ next if @states.include?(state)
153
+
154
+ raise DefinitionError,
155
+ "allow_transition references undeclared state #{state.inspect}"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # The receiver for the +signoff do ... end+ block. Every method
5
+ # here mutates the Signoff::Definition it was built with.
6
+ #
7
+ # signoff do
8
+ # state :draft
9
+ # state :manager_review
10
+ # state :approved
11
+ # state :rejected
12
+ #
13
+ # initial_state :draft
14
+ #
15
+ # transition :draft, to: :manager_review
16
+ # transition :manager_review, to: :approved
17
+ #
18
+ # reject_to :rejected
19
+ #
20
+ # allow_transition :manager_review do |user|
21
+ # user.manager?
22
+ # end
23
+ #
24
+ # after_transition do |record, event|
25
+ # WorkflowNotificationJob.perform_later(record.id, event.id)
26
+ # end
27
+ # end
28
+ class DSL
29
+ def initialize(definition)
30
+ @definition = definition
31
+ end
32
+
33
+ # Declare a state. Pass +initial: true+ to mark it as the starting state
34
+ # (equivalent to a separate +initial_state+ call).
35
+ def state(name, initial: false)
36
+ @definition.add_state(name)
37
+ @definition.initial_state = name if initial
38
+ end
39
+
40
+ # Declare several states at once: +states :draft, :review, :approved+.
41
+ def states(*names)
42
+ names.flatten.each { |name| state(name) }
43
+ end
44
+
45
+ # Explicitly set the starting state. Defaults to the first declared state.
46
+ def initial_state(name)
47
+ @definition.initial_state = name
48
+ end
49
+
50
+ # Declare a forward transition. +to+ accepts a single state or an array.
51
+ def transition(from, to:)
52
+ @definition.add_transition(from, to)
53
+ end
54
+
55
+ # The state a record moves to when +reject!+ is called.
56
+ def reject_to(state)
57
+ @definition.reject_state = state
58
+ end
59
+
60
+ # Authorize transitions out of +from_state+. The block receives the acting
61
+ # user (and optionally the record) and must return a truthy value to allow
62
+ # the transition.
63
+ #
64
+ # allow_transition :finance_review do |user, record|
65
+ # user.finance_team? && record.amount <= user.approval_limit
66
+ # end
67
+ def allow_transition(from_state, &block)
68
+ raise DefinitionError, "allow_transition requires a block" unless block
69
+
70
+ @definition.add_authorization(from_state, block)
71
+ end
72
+
73
+ # Run +block+ inside the transition's transaction, before the state column
74
+ # is written. Receives +(record, from_state, to_state)+.
75
+ def before_transition(&block)
76
+ raise DefinitionError, "before_transition requires a block" unless block
77
+
78
+ @definition.before_callbacks << block
79
+ end
80
+
81
+ # Run +block+ after the transition's transaction commits. Receives
82
+ # +(record, event)+. The created event is already persisted, so this is the
83
+ # right place to enqueue jobs or send mail.
84
+ def after_transition(&block)
85
+ raise DefinitionError, "after_transition requires a block" unless block
86
+
87
+ @definition.after_callbacks << block
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Signoff
6
+ # Minimal Rails engine. Its sole job is to expose +app/models+ (so
7
+ # +Signoff::Event+ autoloads in the host application) and the
8
+ # generators under +lib/generators+.
9
+ class Engine < ::Rails::Engine
10
+ # Register the Engine itself (a Rails::Engine responds to +eager_load!+);
11
+ # pushing the bare Signoff module here would crash host boot under
12
+ # eager loading (the production default) with NoMethodError: eager_load!.
13
+ config.eager_load_namespaces << Signoff::Engine
14
+
15
+ config.app_generators do |generator|
16
+ generator.orm :active_record
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ # Base class for every error raised by the gem. Rescue this to catch any
5
+ # approval-workflow specific failure.
6
+ class Error < StandardError; end
7
+
8
+ # Raised while building a workflow definition with the DSL when the
9
+ # definition is invalid (duplicate states, unknown transition targets,
10
+ # missing initial state, ...).
11
+ class DefinitionError < Error; end
12
+
13
+ # Raised at runtime when a transition is not permitted by the workflow
14
+ # definition (no edge from the current state, ambiguous target, rejecting a
15
+ # finalized record, ...).
16
+ class InvalidTransitionError < Error; end
17
+
18
+ # Raised when an authorization guard declared via +allow_transition+ rejects
19
+ # the acting user (or when a user is required but none was supplied).
20
+ class UnauthorizedError < Error; end
21
+
22
+ # Raised when a model has +include Signoff+ but never declared an
23
+ # +signoff do ... end+ block.
24
+ class NotConfiguredError < Error; end
25
+
26
+ # Raised when the workflowable table is missing the state column the workflow
27
+ # expects (run +rails g signoff:model NAME+ or add it yourself).
28
+ class MissingColumnError < Error; end
29
+ end