summaryrefslogtreecommitdiff
path: root/app/models/node_action.rb
blob: 63a99ae836e7b75810bc998b5ad3e4cd79500b73 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class NodeAction < ApplicationRecord
  belongs_to :node, optional: true
  belongs_to :page, optional: true
  belongs_to :user, optional: true

  validates :action, presence: true
  validates :occurred_at, presence: true

  # == Metadata contract ==
  #
  # metadata is written once at creation, never updated. It is the
  # single place anything that must survive deletion of the referenced
  # rows lives; node_id/page_id/user_id are lookup and ordering only.
  # All keys are strings. Pairs are always {"from" => x, "to" => y}.
  # Optional pairs only when the sides differ; booleans only when
  # true; required keys always present. Titles and names are read
  # pinned to I18n.default_locale unless inside a locale-keyed block.
  #
  # Baseline, every entry (written here, not by call sites):
  #   "username"                 -- actor's login at write time
  #   "human_readable_node_name" -- node title, default locale
  #
  # "create":
  #   "title" -- initial title, flat string (NOT a pair)
  #   "path"  -- unique path at creation, flat string. Historical
  #              value only; never a join key.
  #
  # "publish" (any promotion to head; diff computed BEFORE head is
  # re-pointed, over the union of both pages' locales, by head_diff --
  # shared with the backfill):
  #   "via"    -- "draft" | "revision" (rollback). Always written;
  #               absent means a pre-contract entry.
  #   "title"  -- pair, always; "from" null on first publish
  #   "author" -- pair, when the byline changed (incl. first publish)
  #   "tags"   -- pair of arrays, when changed
  #   "assets_changed", "template_changed",
  #   "abstract_changed", "body_changed"
  #            -- the last two for the default locale; page_id links
  #               to the revision for the real diff (never stored)
  #   "translation_diff" -- only when a non-default locale differs:
  #     { "<locale>" => {
  #         "status" -- "added" | "removed" | "changed"
  #         "title"  -- pair, only when it differs; "from" null when
  #                     added, "to" null when removed
  #         "abstract_changed", "body_changed" -- status "changed" only
  #     } }
  #
  # "move" (reparent and/or path change; one entry at the subtree
  # root, descendants get none):
  #   "path" -- pair
  #
  # "trash" (subtree into the Trash; every head in the subtree is
  # demoted first; one entry at the root; snapshots the
  # leaving-public-view state, since Trash holds no heads and destroy
  # can no longer know):
  #   "path"               -- pair; "from" doubles as the restore hint
  #   "was_published"      -- boolean
  #   "demoted_heads"      -- integer count, only when positive
  #   "final_published_at" -- ISO8601 string, only when present
  #
  # "restore_from_trash" (reparent back to a living node; returns as
  # drafts, republication is a separate witnessed act):
  #   "path" -- pair
  #
  # "destroy" (only from inside the Trash, never with children; the
  # entry is written in the same transaction before the row dies):
  #   "path" -- final path, flat string (create-symmetric)
  #   "destroyed_descendants" -- integer, only when positive; one entry
  #                              at the root, per the subtree rule.
  #
  # Reserved: "demote" (via "trash" | "depublish") for an explicit
  # depublish workflow, if ever built.
  #
  # Backfilled entries mirror this vocabulary; diff content is
  # computed, only actor (page.editor) and occurred_at are inferred,
  # inferred_from names the heuristic ("from_node_created_at",
  # "from_page_revision"). Null = witnessed live.
  #
  # The "locale" column is written by no verb; retained.
  #
  # This log records; it does not undo. No IP, session, or user
  # agent, ever. Success only.

  def self.record!(node:, action:, user: nil, page: nil, locale: nil,
                    occurred_at: nil, inferred_from: nil, **extra)
    create!(
      :node          => node,
      :page          => page,
      :user          => user,
      :action        => action,
      :locale        => locale,
      :occurred_at   => occurred_at || Time.now,
      :inferred_from => inferred_from,
      :metadata      => {
        "username"                 => user&.login,
        "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
                                        node&.head&.title || node&.draft&.title
                                      },
      }.merge(extra.stringify_keys)
    )
  end

  # Computes the publish-entry diff between an outgoing head and the
  # page replacing it, in the exact metadata shape the contract above
  # specifies. Pure function of its two arguments -- shared verbatim by
  # publish_draft!, restore_revision!, and the backfill task. Reads
  # translation rows directly, never locale-dependent accessors, so a
  # fallback value is never mistaken for real content. Returns
  # symbol-keyed top level for splatting into record!; nested keys are
  # strings and jsonb serialization stringifies the rest at write time.
  def self.head_diff old_page, new_page
    default = I18n.default_locale

    title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
    diff = { :title => { "from" => title_of.call(old_page),
                         "to"   => title_of.call(new_page) } }
    unless old_page
      diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
      return diff
    end

    old_author, new_author = old_page.user&.login, new_page.user&.login
    diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author

    old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
    diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags

    diff[:template_changed] = true if old_page.template_name != new_page.template_name
    diff[:assets_changed]   = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)

    old_t = old_page.translations.find_by(:locale => default)
    new_t = new_page.translations.find_by(:locale => default)
    diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
    diff[:body_changed]     = true if old_t&.body != new_t&.body

    locales = (old_page.translated_locales | new_page.translated_locales) - [default]
    translation_diff = {}

    locales.sort_by(&:to_s).each do |locale|
      o = old_page.translations.find_by(:locale => locale)
      n = new_page.translations.find_by(:locale => locale)

      if o.nil?
        translation_diff[locale.to_s] = { "status" => "added",
                                          "title" => { "from" => nil, "to" => n.title } }
      elsif n.nil?
        translation_diff[locale.to_s] = { "status" => "removed",
                                          "title" => { "from" => o.title, "to" => nil } }
      elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
        entry = { "status" => "changed" }
        entry["title"]            = { "from" => o.title, "to" => n.title } if o.title != n.title
        entry["abstract_changed"] = true if o.abstract != n.abstract
        entry["body_changed"]     = true if o.body != n.body
        translation_diff[locale.to_s] = entry
      end
    end

    diff[:translation_diff] = translation_diff if translation_diff.any?
    diff
  end

  def actor_name
    metadata["username"] || "unknown"
  end

  def subject_name
    metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
  end

  def diff_link_params
    prev = NodeAction.where(node_id: node_id, action: "publish")
                     .where("id < ?", id)
                     .order(id: :desc).first
    return nil unless prev&.page && page
    { start_revision: prev.page.revision, end_revision: page.revision }
  end
end