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.
data/README.md ADDED
@@ -0,0 +1,794 @@
1
+ # Signoff
2
+
3
+ [![CI](https://github.com/JijoBose/Signoff/actions/workflows/ci.yml/badge.svg)](https://github.com/JijoBose/Signoff/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 %> &rarr; <%= 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).