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