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,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Signoff
6
+ # The behaviour mixed into ActiveRecord models via +include Signoff+.
7
+ # It provides the +signoff+ class macro, the transition methods
8
+ # (+submit!+, +approve!+, +reject!+), state predicates, query scopes and the
9
+ # audit-trail helpers.
10
+ module Model
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ class_attribute :signoff_definition,
15
+ instance_accessor: false,
16
+ default: nil
17
+
18
+ after_initialize :_set_initial_approval_state, if: :new_record?
19
+ end
20
+
21
+ class_methods do
22
+ # Declare the workflow for this model.
23
+ #
24
+ # signoff do
25
+ # state :draft
26
+ # state :approved
27
+ # transition :draft, to: :approved
28
+ # end
29
+ #
30
+ # +column+ overrides the state column for this model (defaults to
31
+ # +Signoff.configuration.default_state_column+).
32
+ def signoff(column: nil, &block)
33
+ raise DefinitionError, "signoff requires a block" unless block
34
+
35
+ definition = Signoff::Definition.new(
36
+ state_column: column || Signoff.configuration.default_state_column
37
+ )
38
+ Signoff::DSL.new(definition).instance_eval(&block)
39
+ definition.validate!
40
+ self.signoff_definition = definition
41
+
42
+ has_many :signoff_events,
43
+ -> { order(created_at: :asc, id: :asc) },
44
+ as: :workflowable,
45
+ class_name: "Signoff::Event",
46
+ inverse_of: :workflowable,
47
+ dependent: Signoff.configuration.dependent
48
+
49
+ _define_signoff_predicates(definition)
50
+ definition
51
+ end
52
+
53
+ # The definition, raising a friendly error if the macro was never called.
54
+ def signoff_definition!
55
+ signoff_definition ||
56
+ raise(NotConfiguredError,
57
+ "#{name} includes Signoff but never declared an " \
58
+ "`signoff do ... end` block")
59
+ end
60
+
61
+ def signoff_column
62
+ signoff_definition!.state_column
63
+ end
64
+
65
+ def signoff_states
66
+ signoff_definition!.states
67
+ end
68
+
69
+ # --- query scopes --------------------------------------------------
70
+
71
+ # Records currently in any of the given states.
72
+ def in_state(*names)
73
+ where(signoff_column => names.flatten.map(&:to_s))
74
+ end
75
+
76
+ # Records that reached a successful terminal state.
77
+ def approved
78
+ in_state(*signoff_definition!.approval_terminal_states)
79
+ end
80
+
81
+ # Records in the reject state.
82
+ def rejected
83
+ reject_state = signoff_definition!.reject_state
84
+ reject_state ? in_state(reject_state) : none
85
+ end
86
+
87
+ # Records that are neither approved nor rejected (still in flight).
88
+ def pending
89
+ definition = signoff_definition!
90
+ finalized = (definition.approval_terminal_states +
91
+ [definition.reject_state].compact).map(&:to_s)
92
+ finalized.empty? ? all : where.not(signoff_column => finalized)
93
+ end
94
+
95
+ def _define_signoff_predicates(definition)
96
+ reserved = %i[approved rejected pending]
97
+ definition.states.each do |state|
98
+ next if reserved.include?(state)
99
+
100
+ predicate = "#{state}?"
101
+ next if method_defined?(predicate) || private_method_defined?(predicate)
102
+
103
+ define_method(predicate) { current_state == state }
104
+ end
105
+ end
106
+ end
107
+
108
+ # --- state -----------------------------------------------------------
109
+
110
+ # The current workflow state as a Symbol.
111
+ def current_state
112
+ definition = self.class.signoff_definition!
113
+ _ensure_state_column!(definition)
114
+ value = self[definition.state_column]
115
+ (value.presence || definition.initial_state).to_sym
116
+ end
117
+
118
+ def approved?
119
+ self.class.signoff_definition!
120
+ .approval_terminal_states.include?(current_state)
121
+ end
122
+
123
+ def rejected?
124
+ current_state == self.class.signoff_definition!.reject_state
125
+ end
126
+
127
+ def pending?
128
+ !approved? && !rejected?
129
+ end
130
+
131
+ # --- transitions -----------------------------------------------------
132
+
133
+ # Advance the record forward and record a "submit" event. Conventionally
134
+ # used for the initial submission out of the draft state.
135
+ def submit!(user: nil, comment: nil, metadata: {}, to: nil,
136
+ ip_address: nil, user_agent: nil)
137
+ _advance!(action: "submit", to: to, user: user, comment: comment,
138
+ metadata: metadata, ip_address: ip_address, user_agent: user_agent)
139
+ end
140
+
141
+ # Advance the record forward and record an "approve" event.
142
+ def approve!(user: nil, comment: nil, metadata: {}, to: nil,
143
+ ip_address: nil, user_agent: nil)
144
+ _advance!(action: "approve", to: to, user: user, comment: comment,
145
+ metadata: metadata, ip_address: ip_address, user_agent: user_agent)
146
+ end
147
+
148
+ # Move the record to the configured reject state and record a "reject"
149
+ # event.
150
+ def reject!(user: nil, comment: nil, metadata: {},
151
+ ip_address: nil, user_agent: nil)
152
+ definition = self.class.signoff_definition!
153
+ unless definition.reject_state
154
+ raise InvalidTransitionError,
155
+ "no reject state configured for #{self.class.name}"
156
+ end
157
+
158
+ from = current_state
159
+ raise InvalidTransitionError, "record is already rejected" if from == definition.reject_state
160
+ if definition.approval_terminal_states.include?(from)
161
+ raise InvalidTransitionError,
162
+ "cannot reject a finalized record (state: #{from})"
163
+ end
164
+
165
+ _perform_transition!(action: "reject", from: from,
166
+ to: definition.reject_state, user: user,
167
+ comment: comment, metadata: metadata,
168
+ ip_address: ip_address, user_agent: user_agent)
169
+ end
170
+
171
+ # --- authorization predicates ---------------------------------------
172
+
173
+ def can_approve?(user = nil)
174
+ definition = self.class.signoff_definition!
175
+ from = current_state
176
+ # A no-argument approve! only proceeds when exactly one forward
177
+ # transition exists (zero => terminal, many => ambiguous).
178
+ return false unless definition.forward_targets(from).size == 1
179
+
180
+ _guard_satisfied?(from, user || Signoff::Current.user)
181
+ end
182
+
183
+ def can_reject?(user = nil)
184
+ definition = self.class.signoff_definition!
185
+ return false unless definition.reject_state
186
+
187
+ from = current_state
188
+ return false if from == definition.reject_state ||
189
+ definition.approval_terminal_states.include?(from)
190
+
191
+ _guard_satisfied?(from, user || Signoff::Current.user)
192
+ end
193
+
194
+ # --- audit helpers ---------------------------------------------------
195
+
196
+ # The full, chronologically ordered event history (preloadable via
197
+ # +includes(:signoff_events)+ to avoid N+1 queries).
198
+ def workflow_history
199
+ signoff_events
200
+ end
201
+
202
+ def last_approval
203
+ _latest_event(conditions: { action: "approve" }) { |e| e.action == "approve" }
204
+ end
205
+
206
+ def last_rejection
207
+ _latest_event(conditions: { action: "reject" }) { |e| e.action == "reject" }
208
+ end
209
+
210
+ # The user that moved the record into a successful terminal state.
211
+ def approved_by
212
+ states = self.class.signoff_definition!
213
+ .approval_terminal_states.map(&:to_s)
214
+ return nil if states.empty?
215
+
216
+ _latest_event(conditions: { to_state: states }) do |e|
217
+ states.include?(e.to_state)
218
+ end&.user
219
+ end
220
+
221
+ private
222
+
223
+ def _set_initial_approval_state
224
+ definition = self.class.signoff_definition
225
+ return unless definition
226
+
227
+ column = definition.state_column
228
+ return unless self.class.table_exists?
229
+ return unless self.class.column_names.include?(column.to_s)
230
+ return if self[column].present?
231
+
232
+ self[column] = definition.initial_state.to_s
233
+ end
234
+
235
+ def _advance!(action:, to:, user:, comment:, metadata:, ip_address:, user_agent:)
236
+ definition = self.class.signoff_definition!
237
+ from = current_state
238
+ target = definition.next_state(from, to: to)
239
+ _perform_transition!(action: action, from: from, to: target, user: user,
240
+ comment: comment, metadata: metadata,
241
+ ip_address: ip_address, user_agent: user_agent)
242
+ end
243
+
244
+ def _perform_transition!(action:, from:, to:, user:, comment:, metadata:,
245
+ ip_address:, user_agent:)
246
+ if new_record?
247
+ raise InvalidTransitionError,
248
+ "persist #{self.class.name} before transitioning it"
249
+ end
250
+
251
+ definition = self.class.signoff_definition!
252
+ column = definition.state_column
253
+ user ||= Signoff::Current.user
254
+ _authorize_transition!(from: from, user: user)
255
+
256
+ event = nil
257
+ wrote = false
258
+ begin
259
+ self.class.transaction do
260
+ # Serialize concurrent transitions on this row with SELECT ... FOR
261
+ # UPDATE on just the state column, then re-assert the precondition
262
+ # against the locked value (the column is the single source of truth)
263
+ # so two racing approvals cannot both win. This locks without reloading
264
+ # the record, preserving in-memory/association state.
265
+ locked = self.class.where(self.class.primary_key => id).lock(true).pick(column)
266
+ actual = locked.presence&.to_s || definition.initial_state.to_s
267
+ unless actual == from.to_s
268
+ raise InvalidTransitionError,
269
+ "#{self.class.name} state changed concurrently " \
270
+ "(expected #{from}, was #{actual})"
271
+ end
272
+
273
+ _run_before_callbacks(definition, from, to)
274
+ _write_state!(definition, to)
275
+ wrote = true
276
+ event = _build_event(action: action, from: from, to: to, user: user,
277
+ comment: comment, metadata: metadata,
278
+ ip_address: ip_address, user_agent: user_agent)
279
+ event.save!
280
+ end
281
+ rescue StandardError
282
+ # Undo the optimistic in-memory state write if the transaction rolled
283
+ # back after we had already changed it.
284
+ self[column] = from.to_s if wrote
285
+ raise
286
+ end
287
+
288
+ _track_persisted_event(event)
289
+ _run_after_callbacks(definition, event)
290
+ event
291
+ end
292
+
293
+ def _write_state!(definition, to)
294
+ column = definition.state_column
295
+ if Signoff.configuration.validate_on_transition
296
+ self[column] = to.to_s
297
+ save!
298
+ else
299
+ updates = { column => to.to_s }
300
+ updates[:updated_at] = Time.current if self.class.column_names.include?("updated_at")
301
+ update_columns(updates)
302
+ end
303
+ end
304
+
305
+ def _authorize_transition!(from:, user:)
306
+ guard = self.class.signoff_definition!.guard_for(from)
307
+ return true unless guard
308
+
309
+ if user.nil?
310
+ raise UnauthorizedError,
311
+ "a user is required to perform a transition from #{from}"
312
+ end
313
+
314
+ unless _call_guard(guard, user)
315
+ raise UnauthorizedError,
316
+ "user is not authorized to transition from #{from}"
317
+ end
318
+
319
+ true
320
+ end
321
+
322
+ def _guard_satisfied?(from, user)
323
+ guard = self.class.signoff_definition!.guard_for(from)
324
+ return true unless guard
325
+ return false if user.nil?
326
+
327
+ !!_call_guard(guard, user)
328
+ rescue StandardError
329
+ false
330
+ end
331
+
332
+ def _call_guard(guard, user)
333
+ arity = guard.arity
334
+ if arity == 2 || arity <= -2
335
+ guard.call(user, self)
336
+ else
337
+ guard.call(user)
338
+ end
339
+ end
340
+
341
+ def _build_event(action:, from:, to:, user:, comment:, metadata:,
342
+ ip_address:, user_agent:)
343
+ configuration = Signoff.configuration
344
+ attributes = {
345
+ action: action.to_s,
346
+ from_state: from&.to_s,
347
+ to_state: to.to_s,
348
+ comment: comment,
349
+ metadata: metadata || {}
350
+ }
351
+ attributes[:user_id] = _user_identifier(user) unless user.nil?
352
+
353
+ if configuration.track_ip_addresses
354
+ ip = ip_address || Signoff::Current.ip_address
355
+ attributes[:ip_address] = ip unless ip.nil?
356
+ end
357
+
358
+ if configuration.store_user_agent
359
+ agent = user_agent || Signoff::Current.user_agent
360
+ attributes[:user_agent] = agent unless agent.nil?
361
+ end
362
+
363
+ # Built standalone (not via the association) so a rolled-back transaction
364
+ # never leaves a phantom event in a loaded association cache.
365
+ Signoff::Event.new(attributes.merge(workflowable: self))
366
+ end
367
+
368
+ def _user_identifier(user)
369
+ user.respond_to?(:id) ? user.id : user
370
+ end
371
+
372
+ # Keep a loaded association cache consistent after a successful commit so
373
+ # read-after-write on a preloaded record stays correct.
374
+ def _track_persisted_event(event)
375
+ association = self.association(:signoff_events)
376
+ association.add_to_target(event) if association.loaded?
377
+ end
378
+
379
+ # Return the most recent matching event. Uses the in-memory collection when
380
+ # the association is already loaded (so +includes(:signoff_events)+
381
+ # eliminates N+1 queries), otherwise issues a targeted query.
382
+ def _latest_event(conditions:, &predicate)
383
+ association = self.association(:signoff_events)
384
+ if association.loaded?
385
+ association.target.select(&predicate).max_by { |e| [e.created_at, e.id] }
386
+ else
387
+ signoff_events
388
+ .where(conditions)
389
+ .reorder(created_at: :desc, id: :desc)
390
+ .first
391
+ end
392
+ end
393
+
394
+ def _run_before_callbacks(definition, from, to)
395
+ definition.before_callbacks.each do |callback|
396
+ _invoke_callback(callback, [self, from, to])
397
+ end
398
+ end
399
+
400
+ def _run_after_callbacks(definition, event)
401
+ definition.after_callbacks.each do |callback|
402
+ _invoke_callback(callback, [self, event])
403
+ end
404
+ end
405
+
406
+ def _invoke_callback(callback, args)
407
+ if callback.arity.negative?
408
+ callback.call(*args)
409
+ else
410
+ callback.call(*args.first(callback.arity))
411
+ end
412
+ end
413
+
414
+ def _ensure_state_column!(definition)
415
+ column = definition.state_column.to_s
416
+ return if self.class.column_names.include?(column)
417
+
418
+ raise MissingColumnError,
419
+ "#{self.class.name} is missing the #{column.inspect} state column. " \
420
+ "Run `rails g signoff:model #{self.class.name}` or add a " \
421
+ "string column named #{column.inspect}."
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signoff
4
+ VERSION = "0.1.0"
5
+ end
data/lib/signoff.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "active_record"
6
+
7
+ require_relative "signoff/version"
8
+ require_relative "signoff/errors"
9
+ require_relative "signoff/configuration"
10
+ require_relative "signoff/definition"
11
+ require_relative "signoff/dsl"
12
+ require_relative "signoff/current"
13
+ require_relative "signoff/model"
14
+ require_relative "signoff/controller"
15
+ require_relative "signoff/engine" if defined?(::Rails::Engine)
16
+
17
+ # Signoff adds concurrency-safe approval workflows with an immutable audit
18
+ # trail to ActiveRecord models.
19
+ #
20
+ # class ExpenseReport < ApplicationRecord
21
+ # include Signoff
22
+ #
23
+ # signoff do
24
+ # state :draft
25
+ # state :manager_review
26
+ # state :approved
27
+ # state :rejected
28
+ #
29
+ # transition :draft, to: :manager_review
30
+ # transition :manager_review, to: :approved
31
+ #
32
+ # reject_to :rejected
33
+ # end
34
+ # end
35
+ #
36
+ # Including the module mixes in Signoff::Model (the +signoff+
37
+ # macro, transition methods, scopes and audit helpers). The same namespace also
38
+ # holds the configuration, the audit Event model and the supporting classes.
39
+ module Signoff
40
+ extend ActiveSupport::Concern
41
+ include Signoff::Model
42
+
43
+ class << self
44
+ # The current global configuration.
45
+ def configuration
46
+ @configuration ||= Configuration.new
47
+ end
48
+ alias_method :config, :configuration
49
+
50
+ # Configure the gem, typically from an initializer:
51
+ #
52
+ # Signoff.configure do |config|
53
+ # config.user_class = "User"
54
+ # config.track_ip_addresses = true
55
+ # end
56
+ def configure
57
+ yield(configuration)
58
+ end
59
+
60
+ # Reset configuration to defaults (primarily useful in tests).
61
+ def reset_configuration!
62
+ @configuration = Configuration.new
63
+ end
64
+ end
65
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: signoff
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jijo Bose
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.1'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.1'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.1'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.1'
52
+ - !ruby/object:Gem::Dependency
53
+ name: pg
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '1.1'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '1.1'
66
+ - !ruby/object:Gem::Dependency
67
+ name: railties
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '7.1'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '9.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '7.1'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '9.1'
86
+ description: |
87
+ Signoff adds convention-over-configuration approval workflows to
88
+ ActiveRecord models. Declare states and transitions with a simple DSL, get
89
+ submit!/approve!/reject! methods, authorization hooks, query scopes and an
90
+ immutable PostgreSQL JSONB audit trail - no external services required.
91
+ email:
92
+ - bosejijo@gmail.com
93
+ executables: []
94
+ extensions: []
95
+ extra_rdoc_files: []
96
+ files:
97
+ - CHANGELOG.md
98
+ - LICENSE.txt
99
+ - README.md
100
+ - app/models/signoff/event.rb
101
+ - lib/generators/signoff/install/USAGE
102
+ - lib/generators/signoff/install/install_generator.rb
103
+ - lib/generators/signoff/install/templates/initializer.rb
104
+ - lib/generators/signoff/install/templates/migration.rb.tt
105
+ - lib/generators/signoff/model/USAGE
106
+ - lib/generators/signoff/model/model_generator.rb
107
+ - lib/generators/signoff/model/templates/migration.rb.tt
108
+ - lib/signoff.rb
109
+ - lib/signoff/configuration.rb
110
+ - lib/signoff/controller.rb
111
+ - lib/signoff/current.rb
112
+ - lib/signoff/definition.rb
113
+ - lib/signoff/dsl.rb
114
+ - lib/signoff/engine.rb
115
+ - lib/signoff/errors.rb
116
+ - lib/signoff/model.rb
117
+ - lib/signoff/version.rb
118
+ homepage: https://github.com/JijoBose/Signoff
119
+ licenses:
120
+ - MIT
121
+ metadata:
122
+ source_code_uri: https://github.com/JijoBose/Signoff
123
+ changelog_uri: https://github.com/JijoBose/Signoff/blob/main/CHANGELOG.md
124
+ bug_tracker_uri: https://github.com/JijoBose/Signoff/issues
125
+ rubygems_mfa_required: 'true'
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 3.2.0
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 4.0.13
141
+ specification_version: 4
142
+ summary: Concurrency-safe approval workflows for ActiveRecord; immutable audit trail.
143
+ test_files: []