rubyforgood / rubyforgood/awbw

Person timeline: normalize PaperTrail changesets across create/update/destroy at read time

Open
#2,250 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
Dominant language
Ruby
Stars
15
Forks
26
Avg merge
12h 42m
Merged PRs (30d)
242

Description

Context

We're versioning financial/membership/registration models with PaperTrail (subject_person_id meta) to build a person-scoped timeline. PaperTrail stores diffs asymmetrically by event, so a naive reader has to special-case events:

event object (full state) object_changes (diff)
create nil {field: [nil, new]}
update prior state {field: [old, new]}
destroy final state nil

Destroy events never populate object_changes; the final state lives only in object.

Decision 1 — normalize changesets at read time

Do not force object_changes onto destroy at write time — it duplicates data already in object, is a semantic fiction ([value, nil]), fights PaperTrail defaults, and only half-fixes the asymmetry (create's object is still nil).

Instead, normalize at read time with a single helper that returns a uniform {field => [before, after]} for any event, deriving destroy's diff from the object snapshot. One tested place; stored data stays honest and minimal.

def timeline_changeset(version)
  case version.event
  when "destroy"
    version.object_deserialized.transform_values { |v| [v, nil] }
  else
    version.changeset # create & update already give [before, after]
  end
end

Decision 2 — actor (whodunnit) is read-time only, no schema change

whodunnit is native PaperTrail (the built-in column — t.string "whodunnit" in db/schema.rb — plus set_paper_trail_whodunnit / PaperTrail.request.whodunnit / version.whodunnit). It already holds the acting user's id, per row, so "changed by" is available straight from the versions table (distinct from subject_person_id, which is the subject).

  • No label snapshot. The only strong case for freezing an actor label was data loss on user deletion — but users with history won't be deleted, so that's moot. A snapshot would also go stale on name changes.
  • No new user_id column. whodunnit already carries the id.
  • Just batched-preload users at read time to kill the current N+1 (User.find_by per row in the partial):
ids   = versions.map(&:whodunnit).compact.map(&:to_i)
users = User.where(id: ids).index_by(&:id)
# per row: users[version.whodunnit.to_i]

Wart: whodunnit is a String, not a typed FK, so no includes(:user) and a .to_i cast is needed. Only if that ergonomics gets in the way, add a typed user_id meta later — a nicety, not a correctness need.

Scope

  • Decide where the normalizer lives (Version decorator / timeline PORO / helper)
  • Route the new person-timeline reader through it
  • Route the existing app/views/application/_papertrail_versions.html.erb partial through it (currently skips destroy diffs entirely: version.event != 'destroy' — should show "everything removed")
  • Replace the actor N+1 in that partial (User.find_by(id: version.whodunnit) per row) with a batched preload
  • Handle nil whodunnit (system / console / seed / rake / background-job actions) — display as "System"/"Unknown" and account for it in any actor filter
  • Spec create/update/destroy cases

Notes

  • Serialization is YAML (no PaperTrail initializer → defaults).
  • object_changes is auto-enabled because the object_changes column exists on versions.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with app/views/application/_papertrail_versions.html.erb and the existing person-timeline reader, then trace how PaperTrail versions are loaded and rendered. Decide where the shared normalizer belongs, route both readers through it, batch-load whodunnit users while handling nil actors, and add specs covering create, update, and destroy events.

Written by the indexing model from the issue text.

Assessment

Tech stack
ruby
Domain
backend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.