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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +79 -0
- data/LICENSE.txt +21 -0
- data/README.md +794 -0
- data/app/models/signoff/event.rb +38 -0
- data/lib/generators/signoff/install/USAGE +13 -0
- data/lib/generators/signoff/install/install_generator.rb +56 -0
- data/lib/generators/signoff/install/templates/initializer.rb +30 -0
- data/lib/generators/signoff/install/templates/migration.rb.tt +32 -0
- data/lib/generators/signoff/model/USAGE +11 -0
- data/lib/generators/signoff/model/model_generator.rb +50 -0
- data/lib/generators/signoff/model/templates/migration.rb.tt +9 -0
- data/lib/signoff/configuration.rb +69 -0
- data/lib/signoff/controller.rb +35 -0
- data/lib/signoff/current.rb +18 -0
- data/lib/signoff/definition.rb +159 -0
- data/lib/signoff/dsl.rb +90 -0
- data/lib/signoff/engine.rb +19 -0
- data/lib/signoff/errors.rb +29 -0
- data/lib/signoff/model.rb +424 -0
- data/lib/signoff/version.rb +5 -0
- data/lib/signoff.rb +65 -0
- metadata +143 -0
data/README.md
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
# Signoff
|
|
2
|
+
|
|
3
|
+
[](https://github.com/JijoBose/Signoff/actions/workflows/ci.yml)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
Concurrency-safe **approval workflows for ActiveRecord** with an immutable audit
|
|
7
|
+
trail โ a drop-in model concern, convention over configuration.
|
|
8
|
+
|
|
9
|
+
Declare states and transitions with a tiny DSL, get `submit!` / `approve!` /
|
|
10
|
+
`reject!` for free, plug in authorization and notifications, and keep an immutable
|
|
11
|
+
PostgreSQL audit trail of every decision โ **no external services required**.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class ExpenseReport < ApplicationRecord
|
|
15
|
+
include Signoff
|
|
16
|
+
|
|
17
|
+
signoff do
|
|
18
|
+
state :draft
|
|
19
|
+
state :manager_review
|
|
20
|
+
state :finance_review
|
|
21
|
+
state :approved
|
|
22
|
+
state :rejected
|
|
23
|
+
|
|
24
|
+
transition :draft, to: :manager_review
|
|
25
|
+
transition :manager_review, to: :finance_review
|
|
26
|
+
transition :finance_review, to: :approved
|
|
27
|
+
|
|
28
|
+
reject_to :rejected
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
report.submit!
|
|
35
|
+
report.approve!(user: current_user, comment: "Looks good")
|
|
36
|
+
report.reject!(user: current_user, comment: "Missing receipts")
|
|
37
|
+
|
|
38
|
+
report.current_state # => :finance_review
|
|
39
|
+
report.workflow_history # => [#<Signoff::Event ...>, ...]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- ๐งฉ **Declarative DSL** โ states, transitions, reject paths, guards and callbacks.
|
|
45
|
+
- โ๏ธ **Convention over configuration** โ sensible defaults, minimal setup.
|
|
46
|
+
- ๐ **PostgreSQL-first** โ an immutable JSONB audit log, properly indexed for millions of rows.
|
|
47
|
+
- ๐ **Authorization hooks** โ `allow_transition` guards keyed to the state being acted on.
|
|
48
|
+
- ๐ฃ **Notifications** โ `after_transition` callbacks that play nicely with ActiveJob & ActionMailer.
|
|
49
|
+
- ๐งพ **Immutable audit trail** โ who did what, when, from where, with comments and metadata.
|
|
50
|
+
- ๐ **Query scopes** โ `approved`, `pending`, `rejected`, `in_state(:x)`.
|
|
51
|
+
- ๐๏ธ **Rails generators** โ install + per-model migrations.
|
|
52
|
+
- ๐ **Rails 7.x & 8.x**, Ruby 3.2+.
|
|
53
|
+
|
|
54
|
+
## Table of Contents
|
|
55
|
+
|
|
56
|
+
- [Requirements](#requirements)
|
|
57
|
+
- [Installation](#installation)
|
|
58
|
+
- [Quick Start](#quick-start)
|
|
59
|
+
- [Using It in a Rails Application](#using-it-in-a-rails-application)
|
|
60
|
+
- [The DSL](#the-dsl)
|
|
61
|
+
- [Instance API](#instance-api)
|
|
62
|
+
- [Query Scopes](#query-scopes)
|
|
63
|
+
- [Authorization](#authorization)
|
|
64
|
+
- [Audit Trail](#audit-trail)
|
|
65
|
+
- [Notifications](#notifications)
|
|
66
|
+
- [Configuration](#configuration)
|
|
67
|
+
- [Performance & Scale](#performance--scale)
|
|
68
|
+
- [Generators](#generators)
|
|
69
|
+
- [Example App](#example-app)
|
|
70
|
+
- [Testing](#testing)
|
|
71
|
+
- [Troubleshooting](#troubleshooting)
|
|
72
|
+
- [Development](#development)
|
|
73
|
+
- [Contributing](#contributing)
|
|
74
|
+
- [License](#license)
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- Ruby **>= 3.2**
|
|
79
|
+
- Rails **7.1 โ 8.x** (`activerecord`, `activesupport`, `railties`)
|
|
80
|
+
- **PostgreSQL** (the audit trail uses a `jsonb` column and a GIN index)
|
|
81
|
+
|
|
82
|
+
> CI verifies every combination of Ruby 3.2 / 3.3 / 3.4 against Rails 7.1, 7.2,
|
|
83
|
+
> 8.0 and 8.1.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
Add the gem to your `Gemfile`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
gem "signoff"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bundle install
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run the installer to create the audit-events migration and the initializer:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
rails generate signoff:install
|
|
103
|
+
rails db:migrate
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Add a state column to each model that has a workflow:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
rails generate signoff:model ExpenseReport
|
|
110
|
+
rails db:migrate
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> The current state is stored in a column on the model itself (default
|
|
114
|
+
> `approval_state`), which keeps state queries fast and indexable. The events
|
|
115
|
+
> table is a separate, immutable audit log.
|
|
116
|
+
|
|
117
|
+
## Quick Start
|
|
118
|
+
|
|
119
|
+
### 1. Declare the workflow
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class ExpenseReport < ApplicationRecord
|
|
123
|
+
include Signoff
|
|
124
|
+
|
|
125
|
+
signoff do
|
|
126
|
+
state :draft
|
|
127
|
+
state :manager_review
|
|
128
|
+
state :finance_review
|
|
129
|
+
state :approved
|
|
130
|
+
state :rejected
|
|
131
|
+
|
|
132
|
+
initial_state :draft # optional; defaults to the first state
|
|
133
|
+
|
|
134
|
+
transition :draft, to: :manager_review
|
|
135
|
+
transition :manager_review, to: :finance_review
|
|
136
|
+
transition :finance_review, to: :approved
|
|
137
|
+
|
|
138
|
+
reject_to :rejected
|
|
139
|
+
|
|
140
|
+
# Only managers can act on a report that is in manager_review:
|
|
141
|
+
allow_transition :manager_review do |user|
|
|
142
|
+
user.manager?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
allow_transition :finance_review do |user|
|
|
146
|
+
user.finance_team?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Fire a notification after each successful transition:
|
|
150
|
+
after_transition do |record, event|
|
|
151
|
+
WorkflowNotificationJob.perform_later(record.id, event.id)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 2. Drive it from your application
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
report = ExpenseReport.create!(title: "Conference travel", amount: 1_200)
|
|
161
|
+
report.current_state # => :draft
|
|
162
|
+
report.pending? # => true
|
|
163
|
+
|
|
164
|
+
report.submit! # draft -> manager_review
|
|
165
|
+
|
|
166
|
+
report.approve!(
|
|
167
|
+
user: current_user, # must satisfy the manager_review guard
|
|
168
|
+
comment: "Looks good"
|
|
169
|
+
) # manager_review -> finance_review
|
|
170
|
+
|
|
171
|
+
report.approve!(user: finance_lead) # finance_review -> approved
|
|
172
|
+
report.approved? # => true
|
|
173
|
+
|
|
174
|
+
report.workflow_history # chronological audit trail
|
|
175
|
+
report.approved_by # => #<User finance_lead>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 3. Reject when needed
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
report.reject!(user: current_user, comment: "Missing receipts")
|
|
182
|
+
report.rejected? # => true
|
|
183
|
+
report.last_rejection.comment # => "Missing receipts"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Using It in a Rails Application
|
|
187
|
+
|
|
188
|
+
A complete, idiomatic integration โ wiring the acting user, routes, a
|
|
189
|
+
controller, views, and notifications. (A runnable version of this lives in
|
|
190
|
+
[`examples/expense_approval/`](examples/expense_approval).)
|
|
191
|
+
|
|
192
|
+
### 1. The model
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# app/models/expense_report.rb
|
|
196
|
+
class ExpenseReport < ApplicationRecord
|
|
197
|
+
include Signoff
|
|
198
|
+
|
|
199
|
+
belongs_to :submitter, class_name: "User"
|
|
200
|
+
|
|
201
|
+
signoff do
|
|
202
|
+
state :draft
|
|
203
|
+
state :manager_review
|
|
204
|
+
state :finance_review
|
|
205
|
+
state :approved
|
|
206
|
+
state :rejected
|
|
207
|
+
|
|
208
|
+
transition :draft, to: :manager_review
|
|
209
|
+
transition :manager_review, to: :finance_review
|
|
210
|
+
transition :finance_review, to: :approved
|
|
211
|
+
|
|
212
|
+
reject_to :rejected
|
|
213
|
+
|
|
214
|
+
allow_transition :manager_review do |user|
|
|
215
|
+
user.manager?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
allow_transition :finance_review do |user|
|
|
219
|
+
user.finance_team?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Runs after the transaction commits, with the persisted event.
|
|
223
|
+
after_transition do |record, event|
|
|
224
|
+
WorkflowNotificationJob.perform_later(record.id, event.id)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Generate the audit table and the model's state column once:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
rails generate signoff:install
|
|
234
|
+
rails generate signoff:model ExpenseReport
|
|
235
|
+
rails db:migrate
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 2. Attribute the acting user automatically
|
|
239
|
+
|
|
240
|
+
Include the controller concern in `ApplicationController`. It populates
|
|
241
|
+
`Signoff::Current` from your `current_user` helper (and the request IP /
|
|
242
|
+
user agent when those are enabled in the initializer), so transitions are
|
|
243
|
+
attributed without passing `user:` everywhere.
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# app/controllers/application_controller.rb
|
|
247
|
+
class ApplicationController < ActionController::Base
|
|
248
|
+
include Signoff::Controller # sets Current.user / ip_address / user_agent
|
|
249
|
+
# Assumes a `current_user` helper (Devise, custom auth, etc.)
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
> Prefer to be explicit, or not using the concern? Just pass the user directly:
|
|
254
|
+
> `report.approve!(user: current_user, comment: "...")`.
|
|
255
|
+
|
|
256
|
+
### 3. Routes
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# config/routes.rb
|
|
260
|
+
Rails.application.routes.draw do
|
|
261
|
+
resources :expense_reports do
|
|
262
|
+
member do
|
|
263
|
+
patch :submit
|
|
264
|
+
patch :approve
|
|
265
|
+
patch :reject
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### 4. Controller
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
# app/controllers/expense_reports_controller.rb
|
|
275
|
+
class ExpenseReportsController < ApplicationController
|
|
276
|
+
before_action :set_report, only: %i[show submit approve reject]
|
|
277
|
+
|
|
278
|
+
# Turn workflow errors into friendly responses instead of 500s.
|
|
279
|
+
rescue_from Signoff::UnauthorizedError do |error|
|
|
280
|
+
redirect_back fallback_location: expense_reports_path, alert: error.message
|
|
281
|
+
end
|
|
282
|
+
rescue_from Signoff::InvalidTransitionError do |error|
|
|
283
|
+
redirect_back fallback_location: expense_reports_path, alert: error.message
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Use the generated scopes for dashboards.
|
|
287
|
+
def index
|
|
288
|
+
@pending = ExpenseReport.pending.order(created_at: :desc)
|
|
289
|
+
@approved = ExpenseReport.approved
|
|
290
|
+
@rejected = ExpenseReport.rejected
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Preload the audit trail (and the acting users) to avoid N+1 queries.
|
|
294
|
+
def show
|
|
295
|
+
@report = ExpenseReport.includes(signoff_events: :user).find(params[:id])
|
|
296
|
+
@history = @report.workflow_history
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def submit
|
|
300
|
+
@report.submit!(comment: transition_params[:comment])
|
|
301
|
+
redirect_to @report, notice: "Submitted for review."
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def approve
|
|
305
|
+
# The acting user comes from current_user via Signoff::Controller.
|
|
306
|
+
@report.approve!(comment: transition_params[:comment])
|
|
307
|
+
redirect_to @report, notice: "Approved."
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def reject
|
|
311
|
+
@report.reject!(comment: transition_params[:comment])
|
|
312
|
+
redirect_to @report, notice: "Rejected."
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
def set_report
|
|
318
|
+
@report = ExpenseReport.find(params[:id])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def transition_params
|
|
322
|
+
params.permit(:comment)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### 5. Views
|
|
328
|
+
|
|
329
|
+
Render the current state and only the actions the current user is actually
|
|
330
|
+
allowed to perform (`can_approve?` / `can_reject?` never raise):
|
|
331
|
+
|
|
332
|
+
```erb
|
|
333
|
+
<%# app/views/expense_reports/show.html.erb %>
|
|
334
|
+
<h1><%= @report.title %></h1>
|
|
335
|
+
|
|
336
|
+
<p>
|
|
337
|
+
Status:
|
|
338
|
+
<span class="badge badge-<%= @report.current_state %>">
|
|
339
|
+
<%= @report.current_state.to_s.humanize %>
|
|
340
|
+
</span>
|
|
341
|
+
</p>
|
|
342
|
+
|
|
343
|
+
<%= form_with url: nil do %>
|
|
344
|
+
<%# Show "Submit" only while the report is a draft %>
|
|
345
|
+
<% if @report.draft? %>
|
|
346
|
+
<%= button_to "Submit for review", submit_expense_report_path(@report), method: :patch %>
|
|
347
|
+
<% end %>
|
|
348
|
+
|
|
349
|
+
<% if @report.can_approve?(current_user) %>
|
|
350
|
+
<%= button_to "Approve", approve_expense_report_path(@report), method: :patch %>
|
|
351
|
+
<% end %>
|
|
352
|
+
|
|
353
|
+
<% if @report.can_reject?(current_user) %>
|
|
354
|
+
<%= button_to "Reject", reject_expense_report_path(@report), method: :patch %>
|
|
355
|
+
<% end %>
|
|
356
|
+
<% end %>
|
|
357
|
+
|
|
358
|
+
<h2>Audit trail</h2>
|
|
359
|
+
<table>
|
|
360
|
+
<thead>
|
|
361
|
+
<tr><th>When</th><th>Action</th><th>Transition</th><th>By</th><th>Comment</th></tr>
|
|
362
|
+
</thead>
|
|
363
|
+
<tbody>
|
|
364
|
+
<% @history.each do |event| %>
|
|
365
|
+
<tr>
|
|
366
|
+
<td><%= event.created_at.to_fs(:short) %></td>
|
|
367
|
+
<td><%= event.action.humanize %></td>
|
|
368
|
+
<td><%= event.from_state %> → <%= event.to_state %></td>
|
|
369
|
+
<td><%= event.user&.name || "system" %></td>
|
|
370
|
+
<td><%= event.comment %></td>
|
|
371
|
+
</tr>
|
|
372
|
+
<% end %>
|
|
373
|
+
</tbody>
|
|
374
|
+
</table>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
A dashboard built from the scopes:
|
|
378
|
+
|
|
379
|
+
```erb
|
|
380
|
+
<%# app/views/expense_reports/index.html.erb %>
|
|
381
|
+
<h2>Awaiting a decision (<%= @pending.size %>)</h2>
|
|
382
|
+
<%= render @pending %>
|
|
383
|
+
|
|
384
|
+
<h2>Approved (<%= @approved.size %>)</h2>
|
|
385
|
+
<%= render @approved %>
|
|
386
|
+
|
|
387
|
+
<h2>Rejected (<%= @rejected.size %>)</h2>
|
|
388
|
+
<%= render @rejected %>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### 6. Notifications (ActiveJob + ActionMailer)
|
|
392
|
+
|
|
393
|
+
The model's `after_transition` hook enqueues this job after each transition
|
|
394
|
+
commits, so the event row is guaranteed to exist when the job runs:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
# app/jobs/workflow_notification_job.rb
|
|
398
|
+
class WorkflowNotificationJob < ApplicationJob
|
|
399
|
+
queue_as :default
|
|
400
|
+
|
|
401
|
+
def perform(record_id, event_id)
|
|
402
|
+
event = Signoff::Event.find(event_id)
|
|
403
|
+
report = event.workflowable
|
|
404
|
+
|
|
405
|
+
case event.action
|
|
406
|
+
when "submit" then ApprovalMailer.with(report: report, event: event).submitted.deliver_later
|
|
407
|
+
when "approve" then ApprovalMailer.with(report: report, event: event).advanced.deliver_later
|
|
408
|
+
when "reject" then ApprovalMailer.with(report: report, event: event).rejected.deliver_later
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# app/mailers/approval_mailer.rb
|
|
416
|
+
class ApprovalMailer < ApplicationMailer
|
|
417
|
+
def submitted
|
|
418
|
+
@report = params[:report]
|
|
419
|
+
mail(to: User.managers.pluck(:email), subject: "Expense report awaiting your review")
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def advanced
|
|
423
|
+
@report = params[:report]
|
|
424
|
+
mail(to: User.finance_team.pluck(:email), subject: "Expense report ready for finance review")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def rejected
|
|
428
|
+
@report = params[:report]
|
|
429
|
+
@event = params[:event]
|
|
430
|
+
mail(to: @report.submitter.email, subject: "Your expense report was rejected")
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### 7. Testing the integration
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
# spec/requests/expense_report_approvals_spec.rb
|
|
439
|
+
RSpec.describe "Expense report approvals", type: :request do
|
|
440
|
+
it "lets a manager approve a submitted report" do
|
|
441
|
+
report = ExpenseReport.create!(title: "Travel", amount: 500, submitter: create(:user))
|
|
442
|
+
report.submit!
|
|
443
|
+
sign_in create(:user, manager: true)
|
|
444
|
+
|
|
445
|
+
patch approve_expense_report_path(report), params: { comment: "Looks good" }
|
|
446
|
+
|
|
447
|
+
expect(report.reload.current_state).to eq(:finance_review)
|
|
448
|
+
expect(report.last_approval.comment).to eq("Looks good")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it "blocks an employee and surfaces the error" do
|
|
452
|
+
report = ExpenseReport.create!(title: "Travel", amount: 500, submitter: create(:user))
|
|
453
|
+
report.submit!
|
|
454
|
+
sign_in create(:user) # not a manager
|
|
455
|
+
|
|
456
|
+
patch approve_expense_report_path(report), params: { comment: "nope" }
|
|
457
|
+
|
|
458
|
+
expect(response).to redirect_to(expense_reports_path)
|
|
459
|
+
expect(report.reload.current_state).to eq(:manager_review)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## The DSL
|
|
465
|
+
|
|
466
|
+
Everything goes inside `signoff do ... end`. Pass `column:` to override
|
|
467
|
+
the state column for a single model (e.g. `signoff(column: :workflow_state) do`).
|
|
468
|
+
|
|
469
|
+
| DSL method | Description |
|
|
470
|
+
| --- | --- |
|
|
471
|
+
| `state(name, initial: false)` | Declare a state. `initial: true` marks the start state. |
|
|
472
|
+
| `states(*names)` | Declare several states at once. |
|
|
473
|
+
| `initial_state(name)` | Set the start state explicitly (defaults to the first declared). |
|
|
474
|
+
| `transition(from, to:)` | Declare a forward transition. `to:` accepts a symbol or an array. |
|
|
475
|
+
| `reject_to(state)` | The state `reject!` moves a record into. |
|
|
476
|
+
| `allow_transition(from) { \|user[, record]\| ... }` | Authorize transitions **out of** `from`. |
|
|
477
|
+
| `before_transition { \|record, from, to\| ... }` | Run inside the transaction, before the state is written. |
|
|
478
|
+
| `after_transition { \|record, event\| ... }` | Run after the transaction commits (great for jobs/mail). |
|
|
479
|
+
|
|
480
|
+
Definitions are validated when the class loads. You'll get a descriptive
|
|
481
|
+
`Signoff::DefinitionError` for duplicate states, transitions to/from
|
|
482
|
+
undeclared states, a missing initial state, or an undeclared reject state.
|
|
483
|
+
|
|
484
|
+
## Instance API
|
|
485
|
+
|
|
486
|
+
Including `Signoff` and declaring a workflow generates:
|
|
487
|
+
|
|
488
|
+
### Transitions
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
report.submit!(user: nil, comment: nil, metadata: {}, to: nil)
|
|
492
|
+
report.approve!(user: nil, comment: nil, metadata: {}, to: nil)
|
|
493
|
+
report.reject!(user: nil, comment: nil, metadata: {})
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
- `submit!` and `approve!` both advance the **single forward transition** from the
|
|
497
|
+
current state; they differ only in the audit `action` recorded (`"submit"` vs
|
|
498
|
+
`"approve"`). Use `submit!` for the first step out of `draft` by convention.
|
|
499
|
+
- When a state has **more than one** forward transition, pass `to:` to disambiguate
|
|
500
|
+
(`report.approve!(to: :finance_review)`).
|
|
501
|
+
- `reject!` moves the record to the `reject_to` state.
|
|
502
|
+
- All accept `ip_address:` and `user_agent:` keyword overrides (see
|
|
503
|
+
[Configuration](#configuration)).
|
|
504
|
+
- Each returns the created `Signoff::Event`.
|
|
505
|
+
|
|
506
|
+
### State & predicates
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
report.current_state # => :manager_review (a Symbol)
|
|
510
|
+
|
|
511
|
+
report.pending? # not approved and not rejected
|
|
512
|
+
report.approved? # in a successful terminal state
|
|
513
|
+
report.rejected? # in the reject state
|
|
514
|
+
|
|
515
|
+
# One predicate per declared state:
|
|
516
|
+
report.draft?
|
|
517
|
+
report.manager_review?
|
|
518
|
+
report.finance_review?
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Authorization predicates
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
report.can_approve?(current_user) # => true / false (never raises)
|
|
525
|
+
report.can_reject?(current_user) # => true / false
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Both fall back to `Signoff::Current.user` when no argument is given.
|
|
529
|
+
|
|
530
|
+
### Audit helpers
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
report.workflow_history # all events, chronological, preloadable
|
|
534
|
+
report.last_approval # most recent "approve" event
|
|
535
|
+
report.last_rejection # most recent "reject" event
|
|
536
|
+
report.approved_by # the user who moved it into a terminal "approved" state
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Query Scopes
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
ExpenseReport.approved # in a successful terminal state
|
|
543
|
+
ExpenseReport.pending # neither approved nor rejected
|
|
544
|
+
ExpenseReport.rejected # in the reject state
|
|
545
|
+
ExpenseReport.in_state(:finance_review)
|
|
546
|
+
ExpenseReport.in_state(:draft, :manager_review)
|
|
547
|
+
|
|
548
|
+
# Fully chainable:
|
|
549
|
+
ExpenseReport.where(user: current_user).pending.order(created_at: :desc)
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Scopes filter on the indexed state column, so they stay fast at scale.
|
|
553
|
+
|
|
554
|
+
## Authorization
|
|
555
|
+
|
|
556
|
+
`allow_transition` guards are keyed by the **state the record is in** when the
|
|
557
|
+
action happens โ i.e. "who is allowed to act on a record currently in this state".
|
|
558
|
+
|
|
559
|
+
```ruby
|
|
560
|
+
signoff do
|
|
561
|
+
# ...
|
|
562
|
+
allow_transition :manager_review do |user|
|
|
563
|
+
user.manager?
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Guards may also receive the record:
|
|
567
|
+
allow_transition :finance_review do |user, record|
|
|
568
|
+
user.finance_team? && record.amount <= user.approval_limit
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
- A blocked transition raises `Signoff::UnauthorizedError` with a
|
|
574
|
+
descriptive message and **leaves the record unchanged** (the state is only
|
|
575
|
+
written inside the transaction, after the guard passes).
|
|
576
|
+
- If a guard is declared but **no user** is supplied (and none is set on
|
|
577
|
+
`Signoff::Current`), an `UnauthorizedError` is raised too.
|
|
578
|
+
- States without a guard are open to anyone (e.g. `draft` โ `submit!`).
|
|
579
|
+
- `can_approve?` / `can_reject?` evaluate the same guards but never raise.
|
|
580
|
+
|
|
581
|
+
## Audit Trail
|
|
582
|
+
|
|
583
|
+
Every transition writes one immutable `Signoff::Event` row:
|
|
584
|
+
|
|
585
|
+
| Column | Notes |
|
|
586
|
+
| --- | --- |
|
|
587
|
+
| `workflowable_type` / `workflowable_id` | Polymorphic owner (the model). |
|
|
588
|
+
| `user_id` | The acting user (nullable). |
|
|
589
|
+
| `action` | `"submit"`, `"approve"`, `"reject"`. |
|
|
590
|
+
| `from_state` / `to_state` | Strings. |
|
|
591
|
+
| `comment` | Free text. |
|
|
592
|
+
| `metadata` | `jsonb`, GIN-indexed. |
|
|
593
|
+
| `ip_address` / `user_agent` | Captured when enabled (see config). |
|
|
594
|
+
| `created_at` | Set automatically; rows are append-only. |
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
event = report.approve!(user: current_user, comment: "Approved", metadata: { source: "web" })
|
|
598
|
+
|
|
599
|
+
event.action # => "approve"
|
|
600
|
+
event.from_state # => "manager_review"
|
|
601
|
+
event.to_state # => "finance_review"
|
|
602
|
+
event.user # => #<User ...>
|
|
603
|
+
event.metadata # => { "source" => "web" }
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
**Events are read-only once persisted** (configurable via
|
|
607
|
+
`config.immutable_events`), guaranteeing a tamper-resistant trail. Useful scopes
|
|
608
|
+
are available too: `Signoff::Event.chronological`, `.recent`,
|
|
609
|
+
`.with_action("approve")`, `.approvals`, `.rejections`.
|
|
610
|
+
|
|
611
|
+
### Capturing request context
|
|
612
|
+
|
|
613
|
+
`Signoff::Current` is an `ActiveSupport::CurrentAttributes` store for the
|
|
614
|
+
acting `user`, `ip_address` and `user_agent`. Populate it automatically from your
|
|
615
|
+
controllers:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
class ApplicationController < ActionController::Base
|
|
619
|
+
include Signoff::Controller # sets Current.user / ip / user_agent
|
|
620
|
+
end
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Then `report.approve!` (with no `user:`) is attributed to `current_user`, and โ when
|
|
624
|
+
`track_ip_addresses` / `store_user_agent` are enabled โ each event records the
|
|
625
|
+
request IP and user agent.
|
|
626
|
+
|
|
627
|
+
## Notifications
|
|
628
|
+
|
|
629
|
+
Use `after_transition` to react once the transition has safely committed:
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
signoff do
|
|
633
|
+
# ...
|
|
634
|
+
after_transition do |record, event|
|
|
635
|
+
WorkflowNotificationJob.perform_later(record.id, event.id)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
class WorkflowNotificationJob < ApplicationJob
|
|
642
|
+
queue_as :default
|
|
643
|
+
|
|
644
|
+
def perform(record_id, event_id)
|
|
645
|
+
event = Signoff::Event.find(event_id)
|
|
646
|
+
record = event.workflowable
|
|
647
|
+
|
|
648
|
+
case event.action
|
|
649
|
+
when "approve" then ApprovalMailer.advanced(record, event).deliver_later
|
|
650
|
+
when "reject" then ApprovalMailer.rejected(record, event).deliver_later
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Because `after_transition` runs **after** the transaction commits, the event row is
|
|
657
|
+
guaranteed to exist by the time your job runs.
|
|
658
|
+
|
|
659
|
+
## Configuration
|
|
660
|
+
|
|
661
|
+
Generated at `config/initializers/signoff.rb`:
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
Signoff.configure do |config|
|
|
665
|
+
config.user_class = "User" # model referenced by Event#user
|
|
666
|
+
config.track_ip_addresses = false # persist request IP on events
|
|
667
|
+
config.store_user_agent = false # persist request user agent on events
|
|
668
|
+
config.default_state_column = :approval_state # default state column for all models
|
|
669
|
+
config.event_table_name = "signoff_events"
|
|
670
|
+
config.validate_on_transition = false # run full record validation on transition
|
|
671
|
+
config.dependent = :delete_all # events strategy when owner is destroyed
|
|
672
|
+
config.immutable_events = true # make persisted events read-only
|
|
673
|
+
end
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
> **Note:** because events are immutable by default, use `:delete_all` or `:nullify`
|
|
677
|
+
> for `config.dependent` โ `:destroy` instantiates each event and is blocked by the
|
|
678
|
+
> read-only guard. Set `config.immutable_events = false` if you need `:destroy`.
|
|
679
|
+
|
|
680
|
+
## Performance & Scale
|
|
681
|
+
|
|
682
|
+
Designed for millions of audit rows:
|
|
683
|
+
|
|
684
|
+
- **State lives in an indexed column** on the model, so `approved` / `pending` /
|
|
685
|
+
`in_state` are simple, index-backed `WHERE` queries โ not correlated subqueries
|
|
686
|
+
over the events table.
|
|
687
|
+
- The install migration indexes `workflowable_type`, `workflowable_id`,
|
|
688
|
+
`created_at`, a composite `(workflowable_type, workflowable_id, created_at)` for
|
|
689
|
+
history reads, a composite `(workflowable_type, workflowable_id, action, created_at)`
|
|
690
|
+
for `last_approval` / `last_rejection` / `approved_by`, `user_id`, and an optional
|
|
691
|
+
**GIN index on `metadata`** (skip it with `--skip-metadata-index` if you never
|
|
692
|
+
query metadata, to avoid write amplification).
|
|
693
|
+
- **Concurrency-safe:** each transition takes a row lock (`SELECT โฆ FOR UPDATE`) and
|
|
694
|
+
re-checks the current state before writing, so two racing `approve!` calls can't
|
|
695
|
+
both succeed โ the loser gets an `InvalidTransitionError`.
|
|
696
|
+
- `workflow_history` is the `signoff_events` association, so preload it to
|
|
697
|
+
avoid N+1 queries:
|
|
698
|
+
|
|
699
|
+
```ruby
|
|
700
|
+
ExpenseReport.includes(:signoff_events).find_each do |report|
|
|
701
|
+
report.workflow_history.each { |event| ... } # no extra queries
|
|
702
|
+
end
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
- Transitions write the state column with a single `UPDATE` and insert one event
|
|
706
|
+
row inside one transaction.
|
|
707
|
+
|
|
708
|
+
## Generators
|
|
709
|
+
|
|
710
|
+
```bash
|
|
711
|
+
# Migration for the events table + the initializer:
|
|
712
|
+
rails generate signoff:install
|
|
713
|
+
# create config/initializers/signoff.rb
|
|
714
|
+
# create db/migrate/XXXX_create_signoff_events.rb
|
|
715
|
+
|
|
716
|
+
# Add the state column to a model's table:
|
|
717
|
+
rails generate signoff:model ExpenseReport
|
|
718
|
+
# create db/migrate/XXXX_add_approval_state_to_expense_reports.rb
|
|
719
|
+
|
|
720
|
+
# Custom column / initial value:
|
|
721
|
+
rails generate signoff:model Invoice --column=workflow_state --initial=pending
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Both generators emit migrations stamped with your app's current
|
|
725
|
+
`ActiveRecord::Migration` version, so they work on Rails 7.x and 8.x alike.
|
|
726
|
+
|
|
727
|
+
## Example App
|
|
728
|
+
|
|
729
|
+
A complete, runnable Rails 8 + PostgreSQL example lives in
|
|
730
|
+
[`examples/expense_approval/`](examples/expense_approval). It wires up `User` and
|
|
731
|
+
`ExpenseReport`, a notification job, the migrations, and a demo script:
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
cd examples/expense_approval
|
|
735
|
+
bundle install
|
|
736
|
+
bin/rails db:create db:migrate db:seed
|
|
737
|
+
bin/rails runner script/demo.rb # walks a report from draft to approved (and a rejection)
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
## Testing
|
|
741
|
+
|
|
742
|
+
The suite runs against a real PostgreSQL database (to exercise JSONB) and reports
|
|
743
|
+
coverage with SimpleCov (enforced โฅ 90%).
|
|
744
|
+
|
|
745
|
+
```bash
|
|
746
|
+
bundle install
|
|
747
|
+
# Point the suite at your PostgreSQL instance:
|
|
748
|
+
export SIGNOFF_PGHOST=localhost SIGNOFF_PGPORT=5432 SIGNOFF_PGUSER=postgres SIGNOFF_PGDATABASE=signoff_test
|
|
749
|
+
createdb signoff_test
|
|
750
|
+
bundle exec rspec
|
|
751
|
+
bundle exec rubocop
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
The harness reads `SIGNOFF_PGHOST`, `SIGNOFF_PGPORT`, `SIGNOFF_PGUSER`, `SIGNOFF_PGPASSWORD`,
|
|
755
|
+
`SIGNOFF_PGDATABASE` (falling back to the standard `PG*` variables). To test a specific
|
|
756
|
+
Rails line, export `RAILS_VERSION` (`7.1`, `7.2`, `8.0`, `8.1`) before `bundle install`.
|
|
757
|
+
|
|
758
|
+
## Troubleshooting
|
|
759
|
+
|
|
760
|
+
**`Signoff::MissingColumnError`** โ the model's table has no state column.
|
|
761
|
+
Run `rails g signoff:model YourModel` (or add an indexed string column
|
|
762
|
+
named `approval_state`) and migrate.
|
|
763
|
+
|
|
764
|
+
**`Signoff::NotConfiguredError`** โ you `include Signoff` but never
|
|
765
|
+
declared a `signoff do ... end` block.
|
|
766
|
+
|
|
767
|
+
**`Signoff::InvalidTransitionError: ambiguous transition`** โ the state has
|
|
768
|
+
multiple forward transitions; pass `to:` to `approve!` / `submit!`.
|
|
769
|
+
|
|
770
|
+
**`ActiveRecord::ReadOnlyRecord` when destroying a record** โ events are immutable, so
|
|
771
|
+
`config.dependent = :destroy` is incompatible. Use `:delete_all` or `:nullify`, or set
|
|
772
|
+
`config.immutable_events = false`.
|
|
773
|
+
|
|
774
|
+
**`Event#user` is `nil` / wrong class** โ set `config.user_class` in the initializer
|
|
775
|
+
(it is read when the `Event` model loads, after initializers run).
|
|
776
|
+
|
|
777
|
+
## Development
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
bin/setup # install dependencies
|
|
781
|
+
bundle exec rspec # run the test suite (needs PostgreSQL)
|
|
782
|
+
bundle exec rubocop # lint
|
|
783
|
+
bin/console # interactive prompt
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
## Contributing
|
|
787
|
+
|
|
788
|
+
Bug reports and pull requests are welcome at
|
|
789
|
+
<https://github.com/JijoBose/Signoff>. This project follows the
|
|
790
|
+
[Contributor Covenant](CODE_OF_CONDUCT.md) code of conduct.
|
|
791
|
+
|
|
792
|
+
## License
|
|
793
|
+
|
|
794
|
+
Available as open source under the terms of the [MIT License](LICENSE.txt).
|