diff --git a/AGENTS.md b/AGENTS.md
index a3ea19ff71..a14ae0cfa5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,7 +51,7 @@ This codebase (Rails 8.1)
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files |
| `app/jobs/` | SolidQueue background jobs | 4 files |
-| `app/models/concerns/` | Shared model modules | 16 concerns |
+| `app/models/concerns/` | Shared model modules | 17 concerns |
### Presentation
@@ -143,6 +143,7 @@ This codebase (Rails 8.1)
| `SectorsTaggable` | Enforces a single primary sector for sector-tagged owners |
| `TagFilterable` | Scope-based filtering by tag names |
| `Trendable` | Trending metrics tracking |
+| `UserStampable` | Stamps `updated_by_id` from `Current.user` on every write (no-op without the column) |
| `WindowsTypeFilterable` | Filter by WindowsType association |
## Controllers
@@ -445,7 +446,7 @@ RuboCop linting on PRs and pushes to main.
## Rake Tasks
-Located in `lib/tasks/` (8 files):
+Located in `lib/tasks/` (9 files):
- `dev.rake` — Development database seeding from XML/CSV
- `rhino_migrator.rake` — Rich text editor migration
- `attachment_report.rake` — Attachment reporting
@@ -454,3 +455,4 @@ Located in `lib/tasks/` (8 files):
- `legacy_user_permissions_to_comments.rake` — Migrate legacy user permissions into comments
- `migrate_sectors.rake` — Sector data migration
- `migrate_workshop_logs.rake` — Workshop log migration
+- `backfill_user_stamps.rake` — Backfill `created_by_id`/`updated_by_id` on legacy rows from the Ahoy trail (`data:backfill_user_stamps`)
diff --git a/app/controllers/monthly_reports_controller.rb b/app/controllers/monthly_reports_controller.rb
index a4dbad9554..58d2afeab1 100644
--- a/app/controllers/monthly_reports_controller.rb
+++ b/app/controllers/monthly_reports_controller.rb
@@ -26,16 +26,12 @@ def index
def show
@monthly_report = MonthlyReport.includes(
- :organization, :windows_type, { created_by: :person },
+ :organization, :windows_type, { created_by: :person }, { updated_by: :person },
{ quotes: :workshop },
{ gallery_assets: { file_attachment: :blob } }
).find(params[:id]).decorate
authorize! @monthly_report
@answers = @monthly_report.report_form_field_answers.includes(:form_field)
- @updated_by = Ahoy::Event.where(resource_type: "MonthlyReport", resource_id: @monthly_report.id)
- .where("name LIKE 'update.%'")
- .order(time: :desc)
- .first&.user
end
private
diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb
index fae4b9508c..3d40639a1a 100644
--- a/app/controllers/story_ideas_controller.rb
+++ b/app/controllers/story_ideas_controller.rb
@@ -16,10 +16,6 @@ def index
def show
authorize! @story_idea
- @updated_by = Ahoy::Event.where(resource_type: "StoryIdea", resource_id: @story_idea.id)
- .where("name LIKE 'update.%'")
- .order(time: :desc)
- .first&.user
end
def new
diff --git a/app/controllers/workshop_logs_controller.rb b/app/controllers/workshop_logs_controller.rb
index 1c1ea989f9..32ab4f2b1e 100644
--- a/app/controllers/workshop_logs_controller.rb
+++ b/app/controllers/workshop_logs_controller.rb
@@ -68,17 +68,13 @@ def create
def show
@workshop_log = WorkshopLog.includes(
- :organization, :windows_type, { created_by: :person },
+ :organization, :windows_type, { created_by: :person }, { updated_by: :person },
{ quotes: :workshop },
{ gallery_assets: { file_attachment: :blob } }
).find(params[:id]).decorate
authorize! @workshop_log
@workshop = @workshop_log.workshop&.decorate
@answers = @workshop_log.report_form_field_answers.includes(:form_field)
- @updated_by = Ahoy::Event.where(resource_type: "WorkshopLog", resource_id: @workshop_log.id)
- .where("name LIKE 'update.%'")
- .order(time: :desc)
- .first&.user
end
def edit
diff --git a/app/controllers/workshop_variation_ideas_controller.rb b/app/controllers/workshop_variation_ideas_controller.rb
index 3ade7ecfd7..4f6ec355ec 100644
--- a/app/controllers/workshop_variation_ideas_controller.rb
+++ b/app/controllers/workshop_variation_ideas_controller.rb
@@ -16,11 +16,6 @@ def index
def show
authorize! @workshop_variation_idea
track_view(@workshop_variation_idea)
- @updated_by = Ahoy::Event.where(resource_type: "WorkshopVariationIdea", resource_id: @workshop_variation_idea.id)
- .where("name LIKE 'update.%'")
- .order(time: :desc)
- .first&.user
-
@workshop = (@workshop_variation_idea.workshop || Workshop.where(id: params[:workshop_id]).last)&.decorate
@bookmark = current_user&.bookmarks&.find_by(bookmarkable: @workshop)
@new_bookmark = @workshop.bookmarks.build
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index dcab6f4685..6cd5e504e2 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -1,6 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
include AhoyTrackable
+ include UserStampable
def bookmarks_count
if self.respond_to?(:bookmarks)
diff --git a/app/models/concerns/ahoy_trackable.rb b/app/models/concerns/ahoy_trackable.rb
index c9e9f9c644..73268cca86 100644
--- a/app/models/concerns/ahoy_trackable.rb
+++ b/app/models/concerns/ahoy_trackable.rb
@@ -40,7 +40,7 @@ def track_create_event
def track_update_event
return if previously_new_record? # Skip the fake "update" that happens right after create
- changes = previous_changes.except("updated_at", "created_at")
+ changes = previous_changes.except("updated_at", "created_at", "created_by_id", "updated_by_id")
assoc_changes = collect_association_changes
return if changes.empty? && assoc_changes.empty?
diff --git a/app/models/concerns/user_stampable.rb b/app/models/concerns/user_stampable.rb
new file mode 100644
index 0000000000..ddc587e1bf
--- /dev/null
+++ b/app/models/concerns/user_stampable.rb
@@ -0,0 +1,28 @@
+module UserStampable
+ extend ActiveSupport::Concern
+
+ # Stamps updated_by_id from Current.user on every write, so the last editor is
+ # recorded on the record itself. (created_by_id is set at create time by the
+ # controllers; only updated_by_id was going unset on updates.) Included on
+ # ApplicationRecord; the guard makes it a no-op for tables without the column. Runs
+ # on before_validation so it satisfies a required belongs_to :updated_by before the
+ # presence check.
+ included do
+ before_validation :stamp_updated_by
+ end
+
+ private
+
+ def stamp_updated_by
+ user = Current.user
+ return unless user
+ return unless has_attribute?(:updated_by_id)
+
+ # Skip when the caller set updated_by_id explicitly (respect it), and when nothing
+ # else changed (don't turn a no-op save into a write / spurious update event).
+ return if updated_by_id_changed?
+ return unless new_record? || changed?
+
+ self.updated_by_id = user.id
+ end
+end
diff --git a/app/models/event.rb b/app/models/event.rb
index 336bcb2051..4d9e128168 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -17,6 +17,7 @@ class Event < ApplicationRecord
has_rich_text :rhino_description
belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :location, optional: true
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :event_registrations, dependent: :destroy
diff --git a/app/models/report.rb b/app/models/report.rb
index ec4d3c85a5..47cc5706a2 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -1,6 +1,7 @@
class Report < ApplicationRecord
belongs_to :owner, polymorphic: true, optional: true
belongs_to :created_by, class_name: "User"
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :organization
belongs_to :windows_type
belongs_to :workshop, optional: true
diff --git a/app/models/resource.rb b/app/models/resource.rb
index 540566486a..d4ccef7ae5 100644
--- a/app/models/resource.rb
+++ b/app/models/resource.rb
@@ -16,6 +16,7 @@ def self.mentionable_rich_text_fields
has_rich_text :rhino_body
belongs_to :created_by, class_name: "User"
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :author, class_name: "Person", optional: true
belongs_to :workshop, optional: true
belongs_to :windows_type, optional: true
diff --git a/app/models/workshop.rb b/app/models/workshop.rb
index 7d6028788c..17816d91d1 100644
--- a/app/models/workshop.rb
+++ b/app/models/workshop.rb
@@ -25,6 +25,7 @@ def self.mentionable_rich_text_fields
belongs_to :windows_type, optional: true
belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :author, class_name: "Person", optional: true
belongs_to :workshop_idea, optional: true
diff --git a/app/models/workshop_log.rb b/app/models/workshop_log.rb
index 8242aadbf1..a80589fd97 100644
--- a/app/models/workshop_log.rb
+++ b/app/models/workshop_log.rb
@@ -1,5 +1,6 @@
class WorkshopLog < ApplicationRecord
belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :organization, optional: true
belongs_to :windows_type, optional: true
belongs_to :workshop, optional: true
diff --git a/app/models/workshop_variation.rb b/app/models/workshop_variation.rb
index dd6ccf95da..994d3c81b1 100644
--- a/app/models/workshop_variation.rb
+++ b/app/models/workshop_variation.rb
@@ -28,6 +28,7 @@ def self.search_by_params(params)
belongs_to :organization, optional: true
belongs_to :windows_type, optional: true
belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
belongs_to :author, class_name: "Person", optional: true
belongs_to :workshop_variation_idea, optional: true
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
diff --git a/app/views/monthly_reports/show.html.erb b/app/views/monthly_reports/show.html.erb
index 2e20eb6006..312d60cf31 100644
--- a/app/views/monthly_reports/show.html.erb
+++ b/app/views/monthly_reports/show.html.erb
@@ -55,11 +55,13 @@
<%= @monthly_report.created_by&.name %>
<%= @monthly_report.created_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
- Updated by:
- <%= @updated_by&.name || "—" %>
-
<%= @monthly_report.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
+ <% if @monthly_report.updated_at != @monthly_report.created_at %>
+
+ Updated by:
+ <%= @monthly_report.updated_by&.name || "—" %>
+
<%= @monthly_report.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
+
+ <% end %>
diff --git a/app/views/story_ideas/show.html.erb b/app/views/story_ideas/show.html.erb
index c54ef8a996..fe1640e049 100644
--- a/app/views/story_ideas/show.html.erb
+++ b/app/views/story_ideas/show.html.erb
@@ -91,11 +91,13 @@
<%= @story_idea.created_by&.name %>
<%= @story_idea.created_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
- Updated by:
- <%= @updated_by&.name || "—" %>
-
<%= @story_idea.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
+ <% if @story_idea.updated_at != @story_idea.created_at %>
+
+ Updated by:
+ <%= @story_idea.updated_by&.name || "—" %>
+
<%= @story_idea.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
+
+ <% end %>
<% if @story_idea.sectors.any? %>
diff --git a/app/views/workshop_logs/show.html.erb b/app/views/workshop_logs/show.html.erb
index 9dd45aa2df..d58b270c8c 100644
--- a/app/views/workshop_logs/show.html.erb
+++ b/app/views/workshop_logs/show.html.erb
@@ -73,11 +73,13 @@
<%= @workshop_log.created_by&.name %>
<%= @workshop_log.created_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
- Updated by:
- <%= @updated_by&.name || "—" %>
-
<%= @workshop_log.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
+ <% if @workshop_log.updated_at != @workshop_log.created_at %>
+
+ Updated by:
+ <%= @workshop_log.updated_by&.name || "—" %>
+
<%= @workshop_log.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
+
+ <% end %>
diff --git a/app/views/workshop_variation_ideas/show.html.erb b/app/views/workshop_variation_ideas/show.html.erb
index 5f677ea1b7..c16e9cbe2d 100644
--- a/app/views/workshop_variation_ideas/show.html.erb
+++ b/app/views/workshop_variation_ideas/show.html.erb
@@ -106,11 +106,13 @@
<%= @workshop_variation_idea.created_by&.name %>
<%= @workshop_variation_idea.created_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
- Updated by:
- <%= @updated_by&.name || "—" %>
-
<%= @workshop_variation_idea.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
-
+ <% if @workshop_variation_idea.updated_at != @workshop_variation_idea.created_at %>
+
+ Updated by:
+ <%= @workshop_variation_idea.updated_by&.name || "—" %>
+
<%= @workshop_variation_idea.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
+
+ <% end %>
diff --git a/db/migrate/20260729123942_add_updated_by_id_to_audited_tables.rb b/db/migrate/20260729123942_add_updated_by_id_to_audited_tables.rb
new file mode 100644
index 0000000000..859d7d1014
--- /dev/null
+++ b/db/migrate/20260729123942_add_updated_by_id_to_audited_tables.rb
@@ -0,0 +1,22 @@
+class AddUpdatedByIdToAuditedTables < ActiveRecord::Migration[8.1]
+ # Tables that already stamp created_by_id but have no matching updated_by_id. With
+ # these columns the UserStampable concern records the last editor on every update.
+ # reports covers MonthlyReport (STI on reports).
+ TABLES = %i[reports workshop_logs workshop_variations resources workshops events].freeze
+
+ def up
+ TABLES.each do |table|
+ add_column table, :updated_by_id, :integer, null: true unless column_exists?(table, :updated_by_id)
+ add_index table, :updated_by_id unless index_exists?(table, :updated_by_id)
+ add_foreign_key table, :users, column: :updated_by_id unless foreign_key_exists?(table, :users, column: :updated_by_id)
+ end
+ end
+
+ def down
+ TABLES.each do |table|
+ remove_foreign_key table, :users, column: :updated_by_id if foreign_key_exists?(table, :users, column: :updated_by_id)
+ remove_index table, :updated_by_id if index_exists?(table, :updated_by_id)
+ remove_column table, :updated_by_id if column_exists?(table, :updated_by_id)
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index da164c312f..c4457228c1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_07_29_020406) do
+ActiveRecord::Schema[8.1].define(version: 2026_07_29_123942) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -565,6 +565,7 @@
t.datetime "start_date", precision: nil
t.string "title"
t.datetime "updated_at", null: false
+ t.integer "updated_by_id"
t.string "videoconference_label", default: "Virtual event"
t.string "videoconference_passcode"
t.string "videoconference_url"
@@ -572,6 +573,7 @@
t.index ["facilitator_training"], name: "index_events_on_facilitator_training"
t.index ["location_id"], name: "index_events_on_location_id"
t.index ["published"], name: "index_events_on_published"
+ t.index ["updated_by_id"], name: "index_events_on_updated_by_id"
end
create_table "faqs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
@@ -1165,6 +1167,7 @@
t.integer "teens_ongoing", default: 0
t.string "type"
t.datetime "updated_at", precision: nil, null: false
+ t.integer "updated_by_id"
t.integer "windows_type_id"
t.integer "workshop_id"
t.string "workshop_name"
@@ -1172,6 +1175,7 @@
t.index ["organization_id"], name: "index_reports_on_organization_id"
t.index ["type", "date"], name: "index_reports_on_type_and_date"
t.index ["type", "organization_id"], name: "index_reports_on_type_and_organization_id"
+ t.index ["updated_by_id"], name: "index_reports_on_updated_by_id"
t.index ["windows_type_id"], name: "index_reports_on_windows_type_id"
t.index ["workshop_id"], name: "index_reports_on_workshop_id"
end
@@ -1199,12 +1203,14 @@
t.boolean "published", default: false, null: false
t.string "title"
t.datetime "updated_at", precision: nil, null: false
+ t.integer "updated_by_id"
t.string "url"
t.integer "windows_type_id"
t.integer "workshop_id"
t.index ["author_id"], name: "index_resources_on_author_id"
t.index ["created_by_id"], name: "index_resources_on_created_by_id"
t.index ["published"], name: "index_resources_on_published"
+ t.index ["updated_by_id"], name: "index_resources_on_updated_by_id"
t.index ["windows_type_id"], name: "index_resources_on_windows_type_id"
t.index ["workshop_id"], name: "index_resources_on_workshop_id"
end
@@ -1517,12 +1523,14 @@
t.integer "total_children", default: 0
t.integer "total_teens", default: 0
t.datetime "updated_at", precision: nil, null: false
+ t.integer "updated_by_id"
t.integer "windows_type_id"
t.date "workshop_held_on"
t.integer "workshop_id"
t.index ["created_by_id"], name: "index_workshop_logs_on_created_by_id"
t.index ["organization_id", "workshop_held_on"], name: "index_workshop_logs_on_org_and_workshop_held_on"
t.index ["organization_id"], name: "index_workshop_logs_on_organization_id"
+ t.index ["updated_by_id"], name: "index_workshop_logs_on_updated_by_id"
t.index ["windows_type_id"], name: "index_workshop_logs_on_windows_type_id"
t.index ["workshop_held_on"], name: "index_workshop_logs_on_workshop_held_on"
t.index ["workshop_id"], name: "index_workshop_logs_on_workshop_id"
@@ -1586,6 +1594,7 @@
t.boolean "publicly_visible", default: false, null: false
t.boolean "published", default: false, null: false
t.datetime "updated_at", precision: nil, null: false
+ t.integer "updated_by_id"
t.integer "variation_id"
t.integer "windows_type_id"
t.integer "workshop_id"
@@ -1595,6 +1604,7 @@
t.index ["created_by_id"], name: "index_workshop_variations_on_created_by_id"
t.index ["organization_id"], name: "index_workshop_variations_on_organization_id"
t.index ["published"], name: "index_workshop_variations_on_published"
+ t.index ["updated_by_id"], name: "index_workshop_variations_on_updated_by_id"
t.index ["windows_type_id"], name: "index_workshop_variations_on_windows_type_id"
t.index ["workshop_id"], name: "index_workshop_variations_on_workshop_id"
t.index ["workshop_variation_idea_id"], name: "index_workshop_variations_on_workshop_variation_idea_id"
@@ -1678,6 +1688,7 @@
t.text "tips_spanish", size: :long
t.string "title"
t.datetime "updated_at", precision: nil, null: false
+ t.integer "updated_by_id"
t.text "visualization", size: :long
t.text "visualization_spanish", size: :long
t.text "warm_up", size: :long
@@ -1694,6 +1705,7 @@
t.index ["title", "full_name", "objective", "materials", "introduction", "demonstration", "opening_circle", "warm_up", "creation", "closing", "notes", "tips", "misc1", "misc2"], name: "workshop_fullsearch", type: :fulltext
t.index ["title"], name: "index_workshops_on_title", type: :fulltext
t.index ["title"], name: "workshop_fullsearch_title", type: :fulltext
+ t.index ["updated_by_id"], name: "index_workshops_on_updated_by_id"
t.index ["windows_type_id"], name: "index_workshops_on_windows_type_id"
t.index ["workshop_idea_id"], name: "index_workshops_on_workshop_idea_id"
t.index ["year", "month"], name: "index_workshops_on_year_and_month"
@@ -1744,6 +1756,7 @@
add_foreign_key "event_staffs", "people"
add_foreign_key "events", "locations"
add_foreign_key "events", "users", column: "created_by_id"
+ add_foreign_key "events", "users", column: "updated_by_id"
add_foreign_key "form_answers", "form_fields"
add_foreign_key "form_answers", "form_submissions"
add_foreign_key "form_builders", "windows_types"
@@ -1784,9 +1797,11 @@
add_foreign_key "report_form_field_answers", "workshop_logs"
add_foreign_key "reports", "organizations"
add_foreign_key "reports", "users", column: "created_by_id"
+ add_foreign_key "reports", "users", column: "updated_by_id"
add_foreign_key "reports", "windows_types"
add_foreign_key "resources", "people", column: "author_id"
add_foreign_key "resources", "users", column: "created_by_id"
+ add_foreign_key "resources", "users", column: "updated_by_id"
add_foreign_key "resources", "windows_types"
add_foreign_key "resources", "workshops"
add_foreign_key "scholarships", "grants"
@@ -1823,6 +1838,7 @@
add_foreign_key "workshop_ideas", "windows_types"
add_foreign_key "workshop_logs", "organizations"
add_foreign_key "workshop_logs", "users", column: "created_by_id"
+ add_foreign_key "workshop_logs", "users", column: "updated_by_id"
add_foreign_key "workshop_logs", "windows_types"
add_foreign_key "workshop_logs", "workshops"
add_foreign_key "workshop_resources", "resources"
@@ -1837,11 +1853,13 @@
add_foreign_key "workshop_variations", "organizations"
add_foreign_key "workshop_variations", "people", column: "author_id"
add_foreign_key "workshop_variations", "users", column: "created_by_id"
+ add_foreign_key "workshop_variations", "users", column: "updated_by_id"
add_foreign_key "workshop_variations", "windows_types"
add_foreign_key "workshop_variations", "workshop_variation_ideas"
add_foreign_key "workshop_variations", "workshops"
add_foreign_key "workshops", "people", column: "author_id"
add_foreign_key "workshops", "users", column: "created_by_id"
+ add_foreign_key "workshops", "users", column: "updated_by_id"
add_foreign_key "workshops", "windows_types"
add_foreign_key "workshops", "workshop_ideas"
end
diff --git a/lib/tasks/backfill_user_stamps.rake b/lib/tasks/backfill_user_stamps.rake
new file mode 100644
index 0000000000..53dafc1349
--- /dev/null
+++ b/lib/tasks/backfill_user_stamps.rake
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+namespace :data do
+ desc "Backfill created_by_id/updated_by_id on legacy rows from the Ahoy lifecycle trail"
+ task backfill_user_stamps: :environment do
+ # Concrete models carrying the stamp columns. Report is the STI base for
+ # MonthlyReport, so scanning it covers both (find_each yields the leaf instances,
+ # so record.class.name matches the Ahoy resource_type).
+ model_classes = [
+ Banner, Comment, CommunityNews, ContinuingEducationRegistration, Event, Grant,
+ Person, ProfessionalLicense, Report, Resource, Story, StoryIdea, User, Workshop,
+ WorkshopIdea, WorkshopLog, WorkshopVariation, WorkshopVariationIdea
+ ]
+
+ model_classes.each do |klass|
+ stamps = klass.column_names & %w[created_by_id updated_by_id]
+ next if stamps.empty?
+
+ scope = klass.where(stamps.map { |c| "#{c} IS NULL" }.join(" OR "))
+ filled = 0
+
+ scope.find_each do |record|
+ updates = {}
+
+ if stamps.include?("created_by_id") && record.created_by_id.nil?
+ updates[:created_by_id] = stamp_user_from_ahoy(record, "create.%", :asc)
+ end
+
+ if stamps.include?("updated_by_id") && record.updated_by_id.nil?
+ updates[:updated_by_id] = stamp_user_from_ahoy(record, "update.%", :desc)
+ end
+
+ updates.compact!
+ next if updates.empty?
+
+ # update_columns: write only the stamp columns, without touching updated_at or
+ # re-firing the stamping / Ahoy tracking callbacks.
+ record.update_columns(updates)
+ filled += 1
+ end
+
+ puts "#{klass.name}: backfilled #{filled} #{"row".pluralize(filled)}"
+ end
+ end
+end
+
+# The user from the record's earliest create / latest update Ahoy event.
+def stamp_user_from_ahoy(record, name_pattern, direction)
+ Ahoy::Event
+ .where(resource_type: record.class.name, resource_id: record.id)
+ .where.not(user_id: nil)
+ .where("name LIKE ?", name_pattern)
+ .order(time: direction)
+ .limit(1)
+ .pick(:user_id)
+end
diff --git a/spec/models/concerns/user_stampable_spec.rb b/spec/models/concerns/user_stampable_spec.rb
new file mode 100644
index 0000000000..e0fb3ee44b
--- /dev/null
+++ b/spec/models/concerns/user_stampable_spec.rb
@@ -0,0 +1,64 @@
+require "rails_helper"
+
+RSpec.describe UserStampable, type: :model do
+ # Banner carries both stamp columns and a required belongs_to :created_by.
+ let(:creator) { create(:user) }
+ let(:editor) { create(:user) }
+
+ def with_current(user, &block)
+ Current.set(user: user, &block)
+ end
+
+ describe "on create" do
+ it "stamps updated_by from Current.user" do
+ banner = with_current(creator) { Banner.create!(content: "Hi", show: true, created_by: creator) }
+
+ expect(banner.updated_by).to eq(creator)
+ end
+
+ it "satisfies a required belongs_to :updated_by without an explicit assignment" do
+ expect { with_current(creator) { Banner.create!(content: "Hi", show: true, created_by: creator) } }
+ .not_to raise_error
+ end
+
+ it "leaves created_by to the caller" do
+ banner = with_current(creator) { Banner.create!(content: "Hi", show: true, created_by: editor) }
+
+ expect(banner.created_by).to eq(editor)
+ end
+ end
+
+ describe "on update" do
+ let!(:banner) { with_current(creator) { Banner.create!(content: "Hi", show: true, created_by: creator) } }
+
+ it "stamps updated_by with the current editor without touching created_by" do
+ with_current(editor) { banner.update!(content: "Edited") }
+
+ expect(banner.reload.created_by).to eq(creator)
+ expect(banner.updated_by).to eq(editor)
+ end
+
+ it "respects an explicitly assigned updated_by" do
+ assigned = create(:user)
+ with_current(editor) { banner.update!(content: "Edited", updated_by: assigned) }
+
+ expect(banner.reload.updated_by).to eq(assigned)
+ end
+
+ it "does not re-stamp on a save that changes nothing" do
+ with_current(editor) { banner.save! }
+
+ expect(banner.reload.updated_by).to eq(creator)
+ end
+ end
+
+ describe "without a Current.user" do
+ it "leaves the stamp columns to whatever the caller set" do
+ banner = with_current(nil) do
+ Banner.create!(content: "Hi", show: true, created_by: creator, updated_by: creator)
+ end
+
+ expect(banner.updated_by).to eq(creator)
+ end
+ end
+end
diff --git a/spec/tasks/backfill_user_stamps_spec.rb b/spec/tasks/backfill_user_stamps_spec.rb
new file mode 100644
index 0000000000..8a087eae94
--- /dev/null
+++ b/spec/tasks/backfill_user_stamps_spec.rb
@@ -0,0 +1,57 @@
+require "rails_helper"
+require "rake"
+
+RSpec.describe "data:backfill_user_stamps" do
+ before(:all) do
+ Rails.application.load_tasks unless Rake::Task.task_defined?("data:backfill_user_stamps")
+ end
+
+ before { Rake::Task["data:backfill_user_stamps"].reenable }
+
+ def run_task
+ original = $stdout
+ $stdout = StringIO.new
+ Rake::Task["data:backfill_user_stamps"].invoke
+ ensure
+ $stdout = original
+ end
+
+ let(:early_editor) { create(:user) }
+ let(:late_editor) { create(:user) }
+
+ it "backfills updated_by_id from the most recent update event's user" do
+ log = create(:workshop_log)
+ log.update_columns(updated_by_id: nil)
+
+ create(:ahoy_event, name: "update.workshop_log", user: early_editor, time: 2.days.ago,
+ properties: { resource_type: "WorkshopLog", resource_id: log.id })
+ create(:ahoy_event, name: "update.workshop_log", user: late_editor, time: 1.hour.ago,
+ properties: { resource_type: "WorkshopLog", resource_id: log.id })
+
+ run_task
+
+ expect(log.reload.updated_by_id).to eq(late_editor.id)
+ end
+
+ it "leaves updated_by_id untouched when there is no update event" do
+ log = create(:workshop_log)
+ log.update_columns(updated_by_id: nil)
+
+ run_task
+
+ expect(log.reload.updated_by_id).to be_nil
+ end
+
+ it "does not overwrite an already-stamped updated_by_id" do
+ existing = create(:user)
+ log = create(:workshop_log)
+ log.update_columns(updated_by_id: existing.id)
+
+ create(:ahoy_event, name: "update.workshop_log", user: late_editor, time: 1.hour.ago,
+ properties: { resource_type: "WorkshopLog", resource_id: log.id })
+
+ run_task
+
+ expect(log.reload.updated_by_id).to eq(existing.id)
+ end
+end