summaryrefslogtreecommitdiff
path: root/app/models/node_action.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/node_action.rb')
-rw-r--r--app/models/node_action.rb169
1 files changed, 169 insertions, 0 deletions
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
new file mode 100644
index 0000000..13bd5ba
--- /dev/null
+++ b/app/models/node_action.rb
@@ -0,0 +1,169 @@
1class NodeAction < ApplicationRecord
2 belongs_to :node, optional: true
3 belongs_to :page, optional: true
4 belongs_to :user, optional: true
5
6 validates :action, presence: true
7 validates :occurred_at, presence: true
8
9 # == Metadata contract ==
10 #
11 # metadata is written once at creation, never updated. It is the
12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # Optional pairs only when the sides differ; booleans only when
16 # true; required keys always present. Titles and names are read
17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 #
19 # Baseline, every entry (written here, not by call sites):
20 # "username" -- actor's login at write time
21 # "human_readable_node_name" -- node title, default locale
22 #
23 # "create":
24 # "title" -- initial title, flat string (NOT a pair)
25 # "path" -- unique path at creation, flat string. Historical
26 # value only; never a join key.
27 #
28 # "publish" (any promotion to head; diff computed BEFORE head is
29 # re-pointed, over the union of both pages' locales, by head_diff --
30 # shared with the backfill):
31 # "via" -- "draft" | "revision" (rollback). Always written;
32 # absent means a pre-contract entry.
33 # "title" -- pair, always; "from" null on first publish
34 # "author" -- pair, when the byline changed (incl. first publish)
35 # "tags" -- pair of arrays, when changed
36 # "assets_changed", "template_changed",
37 # "abstract_changed", "body_changed"
38 # -- the last two for the default locale; page_id links
39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
41 # { "<locale>" => {
42 # "status" -- "added" | "removed" | "changed"
43 # "title" -- pair, only when it differs; "from" null when
44 # added, "to" null when removed
45 # "abstract_changed", "body_changed" -- status "changed" only
46 # } }
47 #
48 # "move" (reparent and/or path change; one entry at the subtree
49 # root, descendants get none):
50 # "path" -- pair
51 #
52 # "trash" (subtree into the Trash; every head in the subtree is
53 # demoted first; one entry at the root; snapshots the
54 # leaving-public-view state, since Trash holds no heads and destroy
55 # can no longer know):
56 # "path" -- pair; "from" doubles as the restore hint
57 # "was_published" -- boolean
58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
60 #
61 # "restore_from_trash" (reparent back to a living node; returns as
62 # drafts, republication is a separate witnessed act):
63 # "path" -- pair
64 #
65 # "destroy" (only from inside the Trash, never with children; the
66 # entry is written in the same transaction before the row dies):
67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
70 #
71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
72 # depublish workflow, if ever built.
73 #
74 # Backfilled entries mirror this vocabulary; diff content is
75 # computed, only actor (page.editor) and occurred_at are inferred,
76 # inferred_from names the heuristic ("from_node_created_at",
77 # "from_page_revision"). Null = witnessed live.
78 #
79 # The "locale" column is written by no verb; retained.
80 #
81 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only.
83
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
85 occurred_at: nil, inferred_from: nil, **extra)
86 create!(
87 :node => node,
88 :page => page,
89 :user => user,
90 :action => action,
91 :locale => locale,
92 :occurred_at => occurred_at || Time.now,
93 :inferred_from => inferred_from,
94 :metadata => {
95 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title
98 },
99 }.merge(extra.stringify_keys)
100 )
101 end
102
103 # Computes the publish-entry diff between an outgoing head and the
104 # page replacing it, in the exact metadata shape the contract above
105 # specifies. Pure function of its two arguments -- shared verbatim by
106 # publish_draft!, restore_revision!, and the backfill task. Reads
107 # translation rows directly, never locale-dependent accessors, so a
108 # fallback value is never mistaken for real content. Returns
109 # symbol-keyed top level for splatting into record!; nested keys are
110 # strings and jsonb serialization stringifies the rest at write time.
111 def self.head_diff old_page, new_page
112 default = I18n.default_locale
113
114 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
115 diff = { :title => { "from" => title_of.call(old_page),
116 "to" => title_of.call(new_page) } }
117 unless old_page
118 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
119 return diff
120 end
121
122 old_author, new_author = old_page.user&.login, new_page.user&.login
123 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
124
125 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
126 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
127
128 diff[:template_changed] = true if old_page.template_name != new_page.template_name
129 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
130
131 old_t = old_page.translations.find_by(:locale => default)
132 new_t = new_page.translations.find_by(:locale => default)
133 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
134 diff[:body_changed] = true if old_t&.body != new_t&.body
135
136 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
137 translation_diff = {}
138
139 locales.sort_by(&:to_s).each do |locale|
140 o = old_page.translations.find_by(:locale => locale)
141 n = new_page.translations.find_by(:locale => locale)
142
143 if o.nil?
144 translation_diff[locale.to_s] = { "status" => "added",
145 "title" => { "from" => nil, "to" => n.title } }
146 elsif n.nil?
147 translation_diff[locale.to_s] = { "status" => "removed",
148 "title" => { "from" => o.title, "to" => nil } }
149 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
150 entry = { "status" => "changed" }
151 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
152 entry["abstract_changed"] = true if o.abstract != n.abstract
153 entry["body_changed"] = true if o.body != n.body
154 translation_diff[locale.to_s] = entry
155 end
156 end
157
158 diff[:translation_diff] = translation_diff if translation_diff.any?
159 diff
160 end
161
162 def actor_name
163 metadata["username"] || "unknown"
164 end
165
166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end
169end