rails_audit_log 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +294 -55
  3. data/Rakefile +5 -0
  4. data/app/assets/stylesheets/rails_audit_log/_01_base.css +32 -0
  5. data/app/assets/stylesheets/rails_audit_log/_02_layout.css +34 -0
  6. data/app/assets/stylesheets/rails_audit_log/_03_table.css +39 -0
  7. data/app/assets/stylesheets/rails_audit_log/_04_badges.css +13 -0
  8. data/app/assets/stylesheets/rails_audit_log/_05_diff.css +94 -0
  9. data/app/assets/stylesheets/rails_audit_log/_06_timeline.css +21 -0
  10. data/app/assets/stylesheets/rails_audit_log/_07_detail.css +15 -0
  11. data/app/assets/stylesheets/rails_audit_log/_08_pagination.css +56 -0
  12. data/app/assets/stylesheets/rails_audit_log/_09_filters.css +54 -0
  13. data/app/assets/stylesheets/rails_audit_log/application.css +1 -15
  14. data/app/concerns/rails_audit_log/auditable.rb +57 -0
  15. data/app/concerns/rails_audit_log/controller.rb +29 -0
  16. data/app/controllers/rails_audit_log/application_controller.rb +13 -0
  17. data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +32 -0
  18. data/app/controllers/rails_audit_log/resources_controller.rb +14 -0
  19. data/app/helpers/rails_audit_log/application_helper.rb +14 -0
  20. data/app/javascript/rails_audit_log/application.js +8 -0
  21. data/app/javascript/rails_audit_log/diff_controller.js +20 -0
  22. data/app/javascript/rails_audit_log/search_controller.js +12 -0
  23. data/app/jobs/rails_audit_log/application_job.rb +1 -0
  24. data/app/models/rails_audit_log/application_record.rb +1 -0
  25. data/app/models/rails_audit_log/audit_log_entry.rb +127 -25
  26. data/app/views/layouts/rails_audit_log/application.html.erb +16 -10
  27. data/app/views/rails_audit_log/audit_log_entries/_diff.html.erb +55 -0
  28. data/app/views/rails_audit_log/audit_log_entries/index.html.erb +76 -0
  29. data/app/views/rails_audit_log/audit_log_entries/show.html.erb +50 -0
  30. data/app/views/rails_audit_log/resources/show.html.erb +35 -0
  31. data/config/importmap.rb +5 -0
  32. data/config/routes.rb +3 -0
  33. data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +10 -0
  34. data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
  35. data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
  36. data/lib/rails_audit_log/engine.rb +29 -0
  37. data/lib/rails_audit_log/matchers.rb +56 -0
  38. data/lib/rails_audit_log/minitest_assertions.rb +36 -0
  39. data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
  40. data/lib/rails_audit_log/test_helpers.rb +20 -0
  41. data/lib/rails_audit_log/version.rb +1 -1
  42. data/lib/rails_audit_log.rb +173 -5
  43. metadata +70 -5
@@ -1,37 +1,87 @@
1
1
  module RailsAuditLog
2
+ # Represents a single audited event (+create+, +update+, or +destroy+) for
3
+ # one ActiveRecord record.
4
+ #
5
+ # == Columns
6
+ #
7
+ # [event] One of <tt>"create"</tt>, <tt>"update"</tt>, <tt>"destroy"</tt>.
8
+ # [item_type] Class name of the audited record (e.g. <tt>"Article"</tt>).
9
+ # [item_id] Primary key of the audited record.
10
+ # [object_changes] JSON hash of attribute changes in <tt>[from, to]</tt> form.
11
+ # All three event types use the same format:
12
+ # +create+ stores <tt>[nil, new]</tt> for every column,
13
+ # +update+ stores <tt>[old, new]</tt> for changed columns only,
14
+ # +destroy+ stores <tt>[final, nil]</tt> for every column.
15
+ # [object] JSON snapshot of the record's full attributes before the
16
+ # change (stored when {RailsAuditLog.store_snapshot} is +true+).
17
+ # [whodunnit_snapshot] Display name of the actor at the time of the change.
18
+ # [actor_type / actor_id] Polymorphic reference to the actor record.
19
+ # [reason] Optional free-text reason string.
20
+ # [metadata] Arbitrary JSON hash (request IP, custom lambdas, etc.).
2
21
  class AuditLogEntry < ApplicationRecord
3
22
  self.table_name = "audit_log_entries"
4
23
 
5
- EVENTS = %w[create update destroy].freeze
24
+ EVENTS = %w[create update destroy].freeze
6
25
  BLOB_COLUMNS = %w[object_changes object metadata].freeze
26
+ PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
7
27
 
28
+ # @api private
8
29
  def self.configure_connection!
9
30
  return unless (opts = RailsAuditLog.connects_to)
10
31
 
11
32
  connects_to(**opts)
12
33
  end
13
34
 
14
- belongs_to :item, polymorphic: true, optional: true
35
+ belongs_to :item, polymorphic: true, optional: true
15
36
  belongs_to :actor, polymorphic: true, optional: true
16
37
 
17
- validates :event, presence: true, inclusion: { in: EVENTS }
38
+ validates :event, presence: true, inclusion: { in: EVENTS }
18
39
  validates :item_type, presence: true
19
- validates :item_id, presence: true
20
- validate :metadata_must_be_a_hash
40
+ validates :item_id, presence: true
41
+ validate :metadata_must_be_a_hash
21
42
 
22
- # Event scopes
43
+ # @!group Event scopes
44
+
45
+ # Entries for +create+ events.
46
+ # @return [ActiveRecord::Relation]
23
47
  scope :created_events, -> { where(event: "create") }
48
+
49
+ # Entries for +update+ events.
50
+ # @return [ActiveRecord::Relation]
24
51
  scope :updated_events, -> { where(event: "update") }
25
- scope :destroyed_events, -> { where(event: "destroy") }
26
52
 
27
- # Deprecated short aliases kept for backwards compatibility
53
+ # Entries for +destroy+ events.
54
+ # @return [ActiveRecord::Relation]
55
+ scope :destroyed_events, -> { where(event: "destroy") }
28
56
 
57
+ # @deprecated Use {.created_events} instead.
29
58
  scope :creates, -> { created_events }
59
+ # @deprecated Use {.updated_events} instead.
30
60
  scope :updates, -> { updated_events }
61
+ # @deprecated Use {.destroyed_events} instead.
31
62
  scope :destroys, -> { destroyed_events }
32
63
 
33
- # Actor / resource scopes
64
+ # @!endgroup
65
+
66
+ # @!group Actor / resource scopes
67
+
68
+ # Entries written by a specific actor.
69
+ #
70
+ # @param actor [ActiveRecord::Base] the actor record to filter by
71
+ # @return [ActiveRecord::Relation]
72
+ # @example
73
+ # AuditLogEntry.by_actor(current_user)
34
74
  scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
75
+
76
+ # Entries for a specific resource class or instance.
77
+ # Pass a class to get all entries for that type; pass an instance for one record.
78
+ #
79
+ # @param resource [Class, ActiveRecord::Base]
80
+ # @return [ActiveRecord::Relation]
81
+ # @example All entries for Article
82
+ # AuditLogEntry.for_resource(Article)
83
+ # @example All entries for one article
84
+ # AuditLogEntry.for_resource(article)
35
85
  scope :for_resource, lambda { |resource|
36
86
  if resource.is_a?(Class)
37
87
  where(item_type: resource.name)
@@ -40,15 +90,61 @@ module RailsAuditLog
40
90
  end
41
91
  }
42
92
 
43
- # Time scopes
93
+ # @!endgroup
94
+
95
+ # @!group Time scopes
96
+
97
+ # Entries created at or after +time+.
98
+ #
99
+ # @param time [Time]
100
+ # @return [ActiveRecord::Relation]
44
101
  scope :since, ->(time) { where(created_at: time..) }
102
+
103
+ # Entries created at or before +time+.
104
+ #
105
+ # @param time [Time]
106
+ # @return [ActiveRecord::Relation]
45
107
  scope :until, ->(time) { where(created_at: ..time) }
46
108
 
47
- # Projection scope omits JSON blob columns for index/listing queries
109
+ # Entries within a named period. Valid keys: <tt>"1h"</tt>, <tt>"24h"</tt>, <tt>"7d"</tt>.
110
+ #
111
+ # @param period [String] one of +PERIODS.keys+
112
+ # @return [ActiveRecord::Relation]
113
+ scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
114
+
115
+ # @!endgroup
116
+
117
+ # Omits the three JSON blob columns (+object_changes+, +object+, +metadata+)
118
+ # from the +SELECT+. Use on index/listing queries where blobs are not
119
+ # displayed to reduce I/O and avoid deserializing large payloads.
120
+ #
121
+ # @return [ActiveRecord::Relation]
48
122
  scope :slim, -> { select(column_names - BLOB_COLUMNS) }
49
123
 
50
- # Instance methods
124
+ # Entries where +object_changes+ contains a key matching +attribute+.
125
+ # Uses <tt>json_extract</tt> on SQLite/MySQL and <tt>->></tt> on PostgreSQL.
126
+ #
127
+ # @param attribute [Symbol, String] the attribute name to filter on
128
+ # @return [ActiveRecord::Relation]
129
+ # @example
130
+ # AuditLogEntry.touching(:title)
131
+ # post.audit_log_entries.updated_events.touching(:published_at)
132
+ scope :touching, ->(attribute) {
133
+ if connection.adapter_name =~ /PostgreSQL/i
134
+ # :nocov:
135
+ where("object_changes->>? IS NOT NULL", attribute.to_s)
136
+ # :nocov:
137
+ else
138
+ where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
139
+ end
140
+ }
51
141
 
142
+ # Reconstructs and returns the record's state *before* this entry's change.
143
+ # Uses the +object+ snapshot when available; falls back to deriving prior
144
+ # state from +object_changes+ (or the live record for +update+ entries).
145
+ #
146
+ # @return [ActiveRecord::Base, nil] an unpersisted instance; +nil+ for
147
+ # +create+ entries (there is no prior state)
52
148
  def reify
53
149
  return nil if event == "create"
54
150
 
@@ -80,18 +176,37 @@ module RailsAuditLog
80
176
  instance
81
177
  end
82
178
 
179
+ # Returns the entry immediately before this one in the version chain for
180
+ # the same record (lower +id+), or +nil+ if this is the first entry.
181
+ #
182
+ # @return [AuditLogEntry, nil]
83
183
  def previous
84
184
  self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
85
185
  end
86
186
 
187
+ # Returns the entry immediately after this one in the version chain for
188
+ # the same record (higher +id+), or +nil+ if this is the last entry.
189
+ #
190
+ # @return [AuditLogEntry, nil]
87
191
  def next
88
192
  self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
89
193
  end
90
194
 
195
+ # Returns the list of attribute (and association) names that changed in
196
+ # this entry, derived from the keys of +object_changes+.
197
+ #
198
+ # @return [Array<String>]
91
199
  def changed_attributes
92
200
  object_changes&.keys || []
93
201
  end
94
202
 
203
+ # Returns +object_changes+ in a named-key format convenient for display.
204
+ #
205
+ # @return [Hash{String => Hash}] keys are attribute names; values are
206
+ # hashes with +:from+ and +:to+ keys
207
+ # @example
208
+ # entry.diff
209
+ # # => { "title" => { from: "Old", to: "New" }, ... }
95
210
  def diff
96
211
  return {} unless object_changes
97
212
 
@@ -103,18 +218,5 @@ module RailsAuditLog
103
218
  def metadata_must_be_a_hash
104
219
  errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
105
220
  end
106
-
107
- public
108
-
109
- # Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
110
- scope :touching, ->(attribute) {
111
- if connection.adapter_name =~ /PostgreSQL/i
112
- # :nocov:
113
- where("object_changes->>? IS NOT NULL", attribute.to_s)
114
- # :nocov:
115
- else
116
- where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
117
- end
118
- }
119
221
  end
120
222
  end
@@ -1,17 +1,23 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
- <title>Rails audit log</title>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Audit Log</title>
7
+ <link rel="icon" href="data:,">
5
8
  <%= csrf_meta_tags %>
6
9
  <%= csp_meta_tag %>
7
-
8
- <%= yield :head %>
9
-
10
- <%= stylesheet_link_tag "rails_audit_log/application", media: "all" %>
10
+ <%= dashboard_stylesheets %>
11
+ <%= javascript_importmap_tags "rails_audit_log" %>
11
12
  </head>
12
13
  <body>
13
-
14
- <%= yield %>
15
-
14
+ <header class="ral-header">
15
+ <div class="ral-header__inner">
16
+ <%= link_to "Audit Log", root_path, class: "ral-header__logo" %>
17
+ </div>
18
+ </header>
19
+ <main class="ral-main">
20
+ <%= yield %>
21
+ </main>
16
22
  </body>
17
- </html>
23
+ </html>
@@ -0,0 +1,55 @@
1
+ <% changes = entry.object_changes.presence %>
2
+ <% if changes %>
3
+ <div class="ral-diff-wrap" data-controller="diff">
4
+ <div class="ral-diff-toggle">
5
+ <button class="ral-diff-btn ral-diff-btn--active"
6
+ data-diff-target="inlineBtn"
7
+ data-action="click->diff#setInline">Inline</button>
8
+ <button class="ral-diff-btn"
9
+ data-diff-target="sideBtn"
10
+ data-action="click->diff#setSide">Side by side</button>
11
+ </div>
12
+
13
+ <table class="ral-diff" data-diff-target="inline">
14
+ <thead>
15
+ <tr>
16
+ <th>Attribute</th>
17
+ <th>Before</th>
18
+ <th>After</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% changes.each do |attr, (before, after)| %>
23
+ <tr>
24
+ <td class="ral-diff__attr"><%= attr %></td>
25
+ <td class="ral-diff__before"><%= format_diff_value(before) %></td>
26
+ <td class="ral-diff__after"><%= format_diff_value(after) %></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <div class="ral-diff-side" data-diff-target="side" hidden>
33
+ <div class="ral-diff-side__panel ral-diff-side__panel--before">
34
+ <div class="ral-diff-side__header">Before</div>
35
+ <% changes.each do |attr, (before, _after)| %>
36
+ <div class="ral-diff-side__row">
37
+ <span class="ral-diff-side__attr"><%= attr %></span>
38
+ <span class="ral-diff-side__value"><%= format_diff_value(before) %></span>
39
+ </div>
40
+ <% end %>
41
+ </div>
42
+ <div class="ral-diff-side__panel ral-diff-side__panel--after">
43
+ <div class="ral-diff-side__header">After</div>
44
+ <% changes.each do |attr, (_before, after)| %>
45
+ <div class="ral-diff-side__row">
46
+ <span class="ral-diff-side__attr"><%= attr %></span>
47
+ <span class="ral-diff-side__value"><%= format_diff_value(after) %></span>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <% else %>
54
+ <p class="ral-muted">No changes recorded.</p>
55
+ <% end %>
@@ -0,0 +1,76 @@
1
+ <div class="ral-page-header">
2
+ <h1 class="ral-page-header__title">Audit Entries</h1>
3
+ <span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
4
+ </div>
5
+
6
+ <%= turbo_frame_tag "ral-entries", data: { turbo_action: "advance" } do %>
7
+ <form class="ral-filters" action="<%= audit_log_entries_path %>" method="get"
8
+ data-controller="search">
9
+ <%= hidden_field_tag :period, @period if @period %>
10
+
11
+ <input type="search" name="q" value="<%= @q %>"
12
+ class="ral-filter-input" placeholder="Filter by actor…"
13
+ autocomplete="off" aria-label="Filter by actor"
14
+ data-action="input->search#filter">
15
+
16
+ <select name="event" class="ral-filter-select" aria-label="Filter by event"
17
+ data-action="change->search#select">
18
+ <option value="">All events</option>
19
+ <% RailsAuditLog::AuditLogEntry::EVENTS.each do |event| %>
20
+ <option value="<%= event %>" <%= "selected" if @event == event %>><%= event.capitalize %></option>
21
+ <% end %>
22
+ </select>
23
+
24
+ <% if @item_types.size > 1 %>
25
+ <select name="item_type" class="ral-filter-select" aria-label="Filter by resource"
26
+ data-action="change->search#select">
27
+ <option value="">All resources</option>
28
+ <% @item_types.each do |type| %>
29
+ <option value="<%= type %>" <%= "selected" if @item_type == type %>><%= type %></option>
30
+ <% end %>
31
+ </select>
32
+ <% end %>
33
+
34
+ <div class="ral-period-filter" role="group" aria-label="Time period">
35
+ <%= link_to "All", audit_log_entries_path(event: @event, item_type: @item_type, q: @q),
36
+ class: "ral-period-btn#{" ral-period-btn--active" if @period.blank?}" %>
37
+ <% RailsAuditLog::AuditLogEntry::PERIODS.each_key do |p| %>
38
+ <%= link_to p, audit_log_entries_path(event: @event, item_type: @item_type, q: @q, period: p),
39
+ class: "ral-period-btn#{" ral-period-btn--active" if @period == p}" %>
40
+ <% end %>
41
+ </div>
42
+
43
+ <% if @event || @item_type || @period || @q %>
44
+ <%= link_to "Clear", audit_log_entries_path, class: "ral-link ral-filters__clear" %>
45
+ <% end %>
46
+ </form>
47
+
48
+ <% if @entries.any? %>
49
+ <div class="ral-table-wrap">
50
+ <table class="ral-table">
51
+ <thead>
52
+ <tr>
53
+ <th>Event</th>
54
+ <th>Resource</th>
55
+ <th>Actor</th>
56
+ <th>When</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ <% @entries.each do |entry| %>
61
+ <tr>
62
+ <td><span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span></td>
63
+ <td><%= link_to "#{entry.item_type} ##{entry.item_id}", resource_path(item_type: entry.item_type, item_id: entry.item_id), class: "ral-link", data: { turbo_frame: "_top" } %></td>
64
+ <td><%= entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : "—") %></td>
65
+ <td class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+
72
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
73
+ <% else %>
74
+ <p class="ral-empty">No audit entries found.</p>
75
+ <% end %>
76
+ <% end %>
@@ -0,0 +1,50 @@
1
+ <div class="ral-page-header">
2
+ <div>
3
+ <p class="ral-breadcrumb">
4
+ <%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %> /
5
+ <%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %>
6
+ </p>
7
+ <h1 class="ral-page-header__title">Entry #<%= @entry.id %></h1>
8
+ </div>
9
+ <nav class="ral-entry-nav" aria-label="Entry navigation">
10
+ <% if (prev_entry = @entry.previous) %>
11
+ <%= link_to "← Previous", audit_log_entry_path(prev_entry), class: "ral-pagination__link" %>
12
+ <% end %>
13
+ <% if (next_entry = @entry.next) %>
14
+ <%= link_to "Next →", audit_log_entry_path(next_entry), class: "ral-pagination__link" %>
15
+ <% end %>
16
+ </nav>
17
+ </div>
18
+
19
+ <div class="ral-card">
20
+ <dl class="ral-meta">
21
+ <div class="ral-meta__row">
22
+ <dt>Event</dt>
23
+ <dd><span class="ral-badge ral-badge--<%= @entry.event %>"><%= @entry.event %></span></dd>
24
+ </div>
25
+ <div class="ral-meta__row">
26
+ <dt>Resource</dt>
27
+ <dd><%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %></dd>
28
+ </div>
29
+ <% actor = @entry.whodunnit_snapshot.presence || (@entry.actor_type ? "#{@entry.actor_type} \##{@entry.actor_id}" : "—") %>
30
+ <div class="ral-meta__row">
31
+ <dt>Actor</dt>
32
+ <dd><%= actor %></dd>
33
+ </div>
34
+ <div class="ral-meta__row">
35
+ <dt>When</dt>
36
+ <dd class="ral-timestamp"><%= @entry.created_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
37
+ </div>
38
+ <% if @entry.reason.present? %>
39
+ <div class="ral-meta__row">
40
+ <dt>Reason</dt>
41
+ <dd><%= @entry.reason %></dd>
42
+ </div>
43
+ <% end %>
44
+ </dl>
45
+ </div>
46
+
47
+ <h2 class="ral-section-title">Changes</h2>
48
+ <div class="ral-card">
49
+ <%= render "diff", entry: @entry %>
50
+ </div>
@@ -0,0 +1,35 @@
1
+ <div class="ral-page-header">
2
+ <div>
3
+ <p class="ral-breadcrumb"><%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %></p>
4
+ <h1 class="ral-page-header__title"><%= @item_type %> #<%= @item_id %></h1>
5
+ </div>
6
+ <span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
7
+ </div>
8
+
9
+ <%= turbo_frame_tag "ral-resource-entries", data: { turbo_action: "advance" } do %>
10
+ <% if @entries.any? %>
11
+ <div class="ral-timeline">
12
+ <% @entries.each do |entry| %>
13
+ <div class="ral-timeline__entry">
14
+ <div class="ral-timeline__header">
15
+ <span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span>
16
+ <span class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></span>
17
+ <% actor = entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : nil) %>
18
+ <% if actor %>
19
+ <span class="ral-muted">by <%= actor %></span>
20
+ <% end %>
21
+ <% if entry.reason.present? %>
22
+ <span class="ral-reason">"<%= entry.reason %>"</span>
23
+ <% end %>
24
+ <%= link_to "Details →", audit_log_entry_path(entry), class: "ral-link ral-timeline__detail-link", data: { turbo_frame: "_top" } %>
25
+ </div>
26
+ <%= render "rails_audit_log/audit_log_entries/diff", entry: entry %>
27
+ </div>
28
+ <% end %>
29
+ </div>
30
+
31
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
32
+ <% else %>
33
+ <p class="ral-empty">No audit entries for this record.</p>
34
+ <% end %>
35
+ <% end %>
@@ -0,0 +1,5 @@
1
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
2
+ pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
+ pin "rails_audit_log", to: "rails_audit_log/application.js"
4
+ pin "rails_audit_log/search_controller", to: "rails_audit_log/search_controller.js"
5
+ pin "rails_audit_log/diff_controller", to: "rails_audit_log/diff_controller.js"
data/config/routes.rb CHANGED
@@ -1,2 +1,5 @@
1
1
  RailsAuditLog::Engine.routes.draw do
2
+ root to: "audit_log_entries#index"
3
+ resources :audit_log_entries, only: [:index, :show]
4
+ get "resources/:item_type/:item_id", to: "resources#show", as: :resource
2
5
  end
@@ -28,4 +28,14 @@ RailsAuditLog.configure do |config|
28
28
 
29
29
  # Route AuditLogEntry to a dedicated database (Rails multi-DB). Default: nil
30
30
  # config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
31
+
32
+ # Number of entries per page in the web dashboard. Default: 25
33
+ # config.page_size = 50
34
+
35
+ # Gate web dashboard access. Block runs in controller context — controller
36
+ # methods like current_user are available directly, or accept the controller
37
+ # as an argument. Falls back to HTTP Basic auth if the block returns falsy.
38
+ # Leave unset to allow unauthenticated access (development default).
39
+ # config.authenticate { current_user&.admin? }
40
+ # config.authenticate { |c| c.current_user&.admin? }
31
41
  end
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module RailsAuditLog
5
+ module Generators
6
+ class MigrateFromPaperTrailGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a data migration that copies PaperTrail versions to audit_log_entries."
12
+
13
+ def create_migration_file
14
+ migration_template(
15
+ "migrate_from_paper_trail.rb",
16
+ "db/migrate/migrate_from_paper_trail.rb"
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,99 @@
1
+ require "yaml"
2
+ require "json"
3
+
4
+ # Data migration: copies PaperTrail `versions` rows to `audit_log_entries`.
5
+ #
6
+ # Column mapping:
7
+ # versions.item_type → audit_log_entries.item_type
8
+ # versions.item_id → audit_log_entries.item_id
9
+ # versions.event → audit_log_entries.event (create/update/destroy only)
10
+ # versions.object_changes → audit_log_entries.object_changes (YAML or JSON → JSON)
11
+ # versions.object → audit_log_entries.object (YAML or JSON → JSON)
12
+ # versions.whodunnit → audit_log_entries.whodunnit_snapshot
13
+ # versions.created_at → audit_log_entries.created_at
14
+ #
15
+ # actor_type / actor_id are not populated — PaperTrail stores the actor
16
+ # only as a string (whodunnit), so polymorphic references cannot be inferred.
17
+ #
18
+ # This migration is irreversible.
19
+ class MigrateFromPaperTrail < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
20
+ BATCH_SIZE = 1_000
21
+ VALID_EVENTS = %w[create update destroy].freeze
22
+
23
+ # Isolated AR classes to avoid coupling to the host app's models.
24
+ class Version < ActiveRecord::Base # @api private
25
+ self.table_name = "versions"
26
+ end
27
+
28
+ class AuditEntry < ActiveRecord::Base # @api private
29
+ self.table_name = "audit_log_entries"
30
+ end
31
+
32
+ def up
33
+ unless table_exists?(:versions)
34
+ say " versions table not found — nothing to migrate."
35
+ return
36
+ end
37
+
38
+ total = Version.count
39
+ migrated = 0
40
+ skipped = 0
41
+
42
+ say_with_time "Migrating #{total} PaperTrail versions → audit_log_entries" do
43
+ Version.in_batches(of: BATCH_SIZE) do |batch|
44
+ rows = batch.filter_map do |v|
45
+ next unless VALID_EVENTS.include?(v.event)
46
+
47
+ {
48
+ event: v.event,
49
+ item_type: v.item_type,
50
+ item_id: v.item_id,
51
+ object_changes: parse_serialized(v.read_attribute_before_type_cast(:object_changes)),
52
+ object: parse_serialized(v.read_attribute_before_type_cast(:object)),
53
+ whodunnit_snapshot: v.whodunnit,
54
+ created_at: v.created_at,
55
+ updated_at: v.created_at
56
+ }
57
+ end
58
+
59
+ skipped += batch.size - rows.size
60
+ migrated += rows.size
61
+ AuditEntry.insert_all(rows) if rows.any?
62
+ end
63
+ end
64
+
65
+ say " Migrated: #{migrated} Skipped (unsupported event): #{skipped}"
66
+ end
67
+
68
+ def down
69
+ raise ActiveRecord::IrreversibleMigration,
70
+ "Cannot reverse PaperTrail migration — rows already written to audit_log_entries " \
71
+ "cannot be reliably distinguished from native entries."
72
+ end
73
+
74
+ private
75
+
76
+ # Parses a PaperTrail serialized column (YAML or JSON) and returns a Hash,
77
+ # or nil if the value is blank or unparseable.
78
+ def parse_serialized(value)
79
+ return nil if value.nil? || value.to_s.strip.empty?
80
+
81
+ begin
82
+ parsed = JSON.parse(value)
83
+ return parsed.is_a?(Hash) ? parsed : nil
84
+ rescue JSON::ParserError
85
+ # fall through to YAML
86
+ end
87
+
88
+ begin
89
+ parsed = YAML.safe_load(
90
+ value,
91
+ permitted_classes: [Symbol, Date, Time, DateTime, BigDecimal,
92
+ ActiveSupport::TimeWithZone, ActiveSupport::Duration]
93
+ )
94
+ parsed.is_a?(Hash) ? parsed : nil
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
@@ -1,7 +1,36 @@
1
+ require "turbo-rails"
2
+ require "importmap-rails"
3
+ require "pagy"
4
+ require "pagy/toolbox/paginators/method"
5
+
1
6
  module RailsAuditLog
7
+ # @api private
2
8
  class Engine < ::Rails::Engine
3
9
  isolate_namespace RailsAuditLog
4
10
 
11
+ config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")
12
+
13
+ initializer "rails_audit_log.pagy" do |app|
14
+ app.config.after_initialize do
15
+ Pagy::OPTIONS[:limit] = RailsAuditLog.page_size
16
+ end
17
+ end
18
+
19
+ initializer "rails_audit_log.assets" do |app|
20
+ if app.config.respond_to?(:assets)
21
+ app.config.assets.paths << root.join("app/assets/stylesheets")
22
+ app.config.assets.paths << root.join("app/assets/images")
23
+ app.config.assets.paths << root.join("app/javascript")
24
+ end
25
+ end
26
+
27
+ initializer "rails_audit_log.importmap", before: "importmap" do |app|
28
+ if app.config.respond_to?(:importmap)
29
+ app.config.importmap.paths << root.join("config/importmap.rb")
30
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
31
+ end
32
+ end
33
+
5
34
  initializer "rails_audit_log.connect_audit_db" do
6
35
  ActiveSupport.on_load(:active_record) do
7
36
  RailsAuditLog::AuditLogEntry.configure_connection!