diff options
Diffstat (limited to 'app/models/node_action.rb')
| -rw-r--r-- | app/models/node_action.rb | 169 |
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 @@ | |||
| 1 | class 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 | ||
| 169 | end | ||
