rivet_cms 0.1.2.pre → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +207 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +17 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +17 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +97 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. data/lib/rivet_cms/routes.rb +0 -49
@@ -1,82 +1,228 @@
1
1
  module RivetCms
2
2
  class Field < ApplicationRecord
3
- has_prefix_id :fld, minimum_length: RivetCms.configuration.prefixed_ids_length, salt: RivetCms.configuration.prefixed_ids_salt, alphabet: RivetCms.configuration.prefixed_ids_alphabet
3
+ include SoftDeletable
4
4
 
5
- # Field widths for form layout
6
- WIDTHS = %w[full half].freeze
7
- attribute :width, :string, default: 'full'
5
+ has_prefix_id :fld
6
+ include OrganizationScoped
8
7
 
9
- belongs_to :content_type
8
+ belongs_to :content_type, optional: true
10
9
  belongs_to :component, optional: true
11
- has_many :field_values, dependent: :destroy
12
-
13
- # Define available field types
14
- FIELD_TYPES = %w[string text integer boolean media relation component]
15
-
16
- validates :name, presence: true, uniqueness: { scope: :content_type_id }
17
- validates :field_type, presence: true, inclusion: { in: FIELD_TYPES }
18
- validates :width, inclusion: { in: WIDTHS }
19
-
20
- # Default scope to order fields by position
21
- default_scope { order(position: :asc) }
22
-
23
- # Set default position before create
10
+ has_many :content_values, dependent: :destroy
11
+
12
+ # Defaulted here rather than in the DB, since MySQL rejects defaults on
13
+ # JSON columns; keeps config a hash regardless of adapter.
14
+ attribute :config, default: -> { {} }
15
+
16
+ enum :field_type, {
17
+ string: 0,
18
+ text: 1,
19
+ rich_text: 2,
20
+ markdown: 3,
21
+ integer: 4,
22
+ boolean: 5,
23
+ image: 6,
24
+ video: 7,
25
+ file: 8,
26
+ reference: 9,
27
+ component: 10,
28
+ date: 11,
29
+ datetime: 12,
30
+ decimal: 13,
31
+ enumeration: 14
32
+ }
33
+
34
+ enum :width, { full: "full", half: "half" }, prefix: true
35
+
36
+ validates :key, presence: true, format: { with: /\A[a-z][a-z0-9_]*\z/, message: "must be lowercase snake_case" }
37
+ validates :key, uniqueness: { scope: :content_type_id, conditions: -> { where(deleted_at: nil) } }, if: -> { content_type_id.present? }
38
+ validates :key, uniqueness: { scope: :component_id, conditions: -> { where(deleted_at: nil) } }, if: -> { component_id.present? }
39
+ validates :label, presence: true
40
+ validates :field_type, presence: true
41
+ validate :belongs_to_one_owner
42
+ validate :components_cannot_embed_components
43
+ validate :config_contents
44
+
45
+ before_validation :derive_key_from_label, on: :create
24
46
  before_create :set_default_position
25
-
26
- # Field type options for select
47
+ before_create :set_default_row
48
+
49
+ scope :ordered, -> { order(row: :asc, position: :asc) }
50
+
51
+ # Human-readable field type labels for UI
52
+ FIELD_TYPE_LABELS = {
53
+ "string" => "Short text",
54
+ "text" => "Long text",
55
+ "rich_text" => "Rich text",
56
+ "markdown" => "Markdown",
57
+ "integer" => "Number",
58
+ "decimal" => "Decimal",
59
+ "enumeration" => "Select",
60
+ "boolean" => "True/False",
61
+ "image" => "Image",
62
+ "video" => "Video",
63
+ "file" => "File",
64
+ "reference" => "Reference",
65
+ "component" => "Component",
66
+ "date" => "Date",
67
+ "datetime" => "Date & Time"
68
+ }.freeze
69
+
70
+ def field_type_label
71
+ FIELD_TYPE_LABELS[field_type] || field_type.humanize
72
+ end
73
+
27
74
  def self.field_types_for_select
28
- [
29
- ['Short text', 'string'],
30
- ['Long text', 'text'],
31
- ['Number', 'integer'],
32
- ['True/False', 'boolean'],
33
- ['Media', 'media'],
34
- ['Relation', 'relation'],
35
- ['Component', 'component']
36
- ]
37
- end
38
-
39
- # Human-readable field type
40
- def field_type_name
41
- case field_type
42
- when 'string' then 'Short text'
43
- when 'text' then 'Long text'
44
- when 'integer' then 'Number'
45
- when 'boolean' then 'True/False'
46
- when 'media' then 'Media'
47
- when 'relation' then 'Relation'
48
- when 'component' then 'Component'
49
- else field_type.humanize
75
+ FIELD_TYPE_LABELS.map { |value, label| [ label, value ] }
76
+ end
77
+
78
+ def attachment?
79
+ image? || video? || file?
80
+ end
81
+
82
+ def self.reorder!(ordered_ids)
83
+ transaction do
84
+ ordered_ids.each_with_index do |id, index|
85
+ where(id: id).update_all(position: index)
86
+ end
50
87
  end
51
88
  end
52
-
53
- # Update positions for a set of fields
54
- def self.update_positions(ordered_ids)
55
- return if ordered_ids.blank?
56
-
89
+
90
+ # Update layout with row structure
91
+ # rows_config: array of arrays, each inner array contains numeric field
92
+ # IDs for that row, e.g. [[1], [2, 3], [4]]. Prefixed ids do not resolve
93
+ # through where(id:) and would silently match nothing.
94
+ def self.update_layout!(rows_config)
57
95
  transaction do
58
- ordered_ids.each_with_index do |prefixed_id, index|
59
- field = find_by_prefix_id(prefixed_id)
60
- field&.update_column(:position, index + 1)
96
+ rows_config.each_with_index do |field_ids, row_index|
97
+ field_ids.each_with_index do |field_id, position_in_row|
98
+ where(id: field_id).update_all(row: row_index, position: position_in_row)
99
+ end
61
100
  end
62
101
  end
63
102
  end
64
-
65
- after_commit :invalidate_api_docs_cache
66
-
103
+
104
+ # Get the other field paired in the same row (if any)
105
+ def paired_field
106
+ return nil unless width_half?
107
+
108
+ sibling_fields.where(row: row, width: "half").where.not(id: id).first
109
+ end
110
+
111
+ def paired?
112
+ paired_field.present?
113
+ end
114
+
115
+ # Move this field to its own new row
116
+ def unpair!
117
+ return unless paired?
118
+
119
+ move_to_own_row!
120
+ end
121
+
122
+ # Move this field to its own new row
123
+ def move_to_own_row!
124
+ max_row = sibling_fields.maximum(:row) || 0
125
+ update!(row: max_row + 1, position: 0)
126
+ end
127
+
128
+ # Pair this field with another field on the same row
129
+ def pair_with!(other_field)
130
+ return unless width_half? && other_field.width_half?
131
+ return if paired? || other_field.paired?
132
+
133
+ other_field.update!(row: row, position: 1)
134
+ end
135
+
67
136
  private
68
-
137
+
138
+ def sibling_fields
139
+ scope = content_type_id.present? ? { content_type_id: content_type_id } : { component_id: component_id }
140
+ self.class.kept.where(scope)
141
+ end
142
+
143
+ def derive_key_from_label
144
+ return if key.present?
145
+
146
+ self.key = label.to_s.parameterize(separator: "_")
147
+ end
148
+
149
+ def components_cannot_embed_components
150
+ return unless component_id.present? && field_type == "component"
151
+
152
+ errors.add(:field_type, "component fields cannot be nested inside components")
153
+ end
154
+
155
+ def config_contents
156
+ validate_pattern_config
157
+ validate_choices_config
158
+ end
159
+
160
+ def validate_pattern_config
161
+ pattern = config&.dig("pattern")
162
+ return if pattern.blank?
163
+
164
+ unless field_type.in?(%w[string text])
165
+ return errors.add(:config, "pattern is only supported on text fields")
166
+ end
167
+
168
+ if pattern.length > SafePattern::MAX_LENGTH
169
+ errors.add(:config, "pattern must be #{SafePattern::MAX_LENGTH} characters or less")
170
+ elsif !SafePattern.valid?(pattern)
171
+ errors.add(:config, "pattern is not a valid regular expression")
172
+ end
173
+ end
174
+
175
+ def validate_choices_config
176
+ choices = config&.dig("choices")
177
+ return if choices.blank?
178
+ return errors.add(:config, "choices are only supported on select fields") unless enumeration?
179
+
180
+ unless choices.is_a?(Array) && choices.all? { |choice| choice.is_a?(::String) && choice.present? && choice.length <= 255 }
181
+ return errors.add(:config, "choices must be a list of short text values")
182
+ end
183
+
184
+ errors.add(:config, "choices must be unique") if choices.uniq.length != choices.length
185
+ errors.add(:config, "allows at most 100 choices") if choices.length > 100
186
+ end
187
+
188
+ def belongs_to_one_owner
189
+ has_content_type = content_type_id.present? || content_type.present?
190
+ has_component = component_id.present? || component.present?
191
+
192
+ if has_content_type && has_component
193
+ errors.add(:base, "Field cannot belong to both content_type and component")
194
+ elsif !has_content_type && !has_component
195
+ errors.add(:base, "Field must belong to either content_type or component")
196
+ end
197
+ end
198
+
69
199
  def set_default_position
70
- return if position.present?
71
-
72
- max_position = content_type.fields.maximum(:position) || 0
200
+ return if position.present? && position > 0
201
+
202
+ max_position = if content_type_id.present?
203
+ self.class.with_discarded.where(content_type_id: content_type_id).maximum(:position) || 0
204
+ elsif component_id.present?
205
+ self.class.with_discarded.where(component_id: component_id).maximum(:position) || 0
206
+ else
207
+ 0
208
+ end
209
+
73
210
  self.position = max_position + 1
74
211
  end
75
212
 
76
- def invalidate_api_docs_cache
77
- if content_type
78
- Rails.cache.delete("rivet_cms_api_docs")
213
+ def set_default_row
214
+ # Always set a new row for new fields (don't skip if row is 0)
215
+ return if persisted? && row.present?
216
+
217
+ max_row = if content_type_id.present?
218
+ self.class.with_discarded.where(content_type_id: content_type_id).maximum(:row) || -1
219
+ elsif component_id.present?
220
+ self.class.with_discarded.where(component_id: component_id).maximum(:row) || -1
221
+ else
222
+ -1
79
223
  end
224
+
225
+ self.row = max_row + 1
80
226
  end
81
227
  end
82
- end
228
+ end
@@ -0,0 +1,109 @@
1
+ module RivetCms
2
+ class MediaAsset < ApplicationRecord
3
+ include OrganizationScoped
4
+
5
+ has_prefix_id :media
6
+
7
+ has_one_attached :file
8
+
9
+ enum :kind, { image: 0, video: 1, file: 2 }
10
+
11
+ validates :file, presence: true
12
+ validate :file_size_within_limit
13
+ validate :file_type_allowed
14
+
15
+ before_save :cache_file_metadata
16
+
17
+ scope :recent, -> { order(created_at: :desc) }
18
+ scope :search, ->(query) {
19
+ pattern = "%#{sanitize_sql_like(query.downcase)}%"
20
+ where("LOWER(filename) LIKE :q OR LOWER(title) LIKE :q OR LOWER(alt) LIKE :q", q: pattern)
21
+ }
22
+
23
+ THUMBNAIL_SIZE = [ 480, 480 ].freeze
24
+
25
+ def url
26
+ return nil unless file.attached?
27
+
28
+ helpers = Rails.application.routes.url_helpers
29
+ if RivetCms.media_host.present?
30
+ helpers.rails_blob_url(file, host: RivetCms.media_host)
31
+ else
32
+ helpers.rails_blob_path(file, only_path: true)
33
+ end
34
+ end
35
+
36
+ # Resized representation for grid/picker cells: a variant for images, a
37
+ # preview (first page/frame) for PDFs and videos when the host has poppler
38
+ # or ffmpeg installed. Nil when neither applies so callers fall back to a
39
+ # glyph. Processing happens lazily on first request.
40
+ def thumbnail_url
41
+ return nil unless file.attached?
42
+
43
+ representation =
44
+ if image? && file.blob.variable?
45
+ file.variant(resize_to_limit: THUMBNAIL_SIZE)
46
+ elsif file.blob.previewable?
47
+ file.preview(resize_to_limit: THUMBNAIL_SIZE)
48
+ end
49
+ return nil unless representation
50
+
51
+ helpers = Rails.application.routes.url_helpers
52
+ if RivetCms.media_host.present?
53
+ helpers.rails_representation_url(representation, host: RivetCms.media_host)
54
+ else
55
+ helpers.rails_representation_path(representation, only_path: true)
56
+ end
57
+ end
58
+
59
+ # Rich text embeds media by URL (which contains the blob signed id),
60
+ # so field references alone don't tell us an asset is in use.
61
+ def embedded_in_content?
62
+ return false unless file.attached?
63
+
64
+ pattern = file.blob.signed_id.gsub(/[!%_]/) { |c| "!#{c}" }
65
+ ContentValue.where("text_value LIKE ? ESCAPE '!'", "%#{pattern}%").exists?
66
+ end
67
+
68
+ def self.kind_for(content_type)
69
+ case content_type.to_s
70
+ when /\Aimage\// then :image
71
+ when /\Avideo\// then :video
72
+ else :file
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def file_size_within_limit
79
+ limit = RivetCms.max_upload_size
80
+ return unless limit && file.attached?
81
+ return if file.byte_size <= limit
82
+
83
+ errors.add(:file, "is too large (max #{ActiveSupport::NumberHelper.number_to_human_size(limit)})")
84
+ end
85
+
86
+ def file_type_allowed
87
+ allowed = RivetCms.allowed_media_types
88
+ return unless allowed && file.attached?
89
+
90
+ blob = file.blob
91
+ blob.identify_without_saving unless blob.identified?
92
+ return if allowed.include?(blob.content_type)
93
+
94
+ errors.add(:file, "type #{blob.content_type} is not allowed")
95
+ end
96
+
97
+ def cache_file_metadata
98
+ return unless file.attached?
99
+
100
+ blob = file.blob
101
+ blob.identify_without_saving unless blob.identified?
102
+
103
+ self.filename = blob.filename.to_s
104
+ self.content_type = blob.content_type
105
+ self.byte_size = blob.byte_size
106
+ self.kind = self.class.kind_for(blob.content_type)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,46 @@
1
+ module RivetCms
2
+ class Organization < ApplicationRecord
3
+ # Organization has no prefix id, so prefixed_ids does not patch its
4
+ # has_many finders; extend explicitly so find accepts prefixed ids.
5
+ PREFIXED_FINDERS = PrefixedIds::Finder::ClassMethods
6
+
7
+ # content_types is soft-delete scoped, so dependent: :destroy would skip
8
+ # discarded rows and leave them holding the FK; destroy those explicitly.
9
+ # Documents are destroyed first because ContentType restricts on them.
10
+ has_many :content_types, dependent: :destroy, extend: PREFIXED_FINDERS
11
+ before_destroy :destroy_all_content, prepend: true
12
+ has_many :components, dependent: :destroy, extend: PREFIXED_FINDERS
13
+ has_many :categories, dependent: :destroy, extend: PREFIXED_FINDERS
14
+ has_many :media_assets, dependent: :destroy, extend: PREFIXED_FINDERS
15
+ has_many :api_tokens, dependent: :destroy, extend: PREFIXED_FINDERS
16
+ # Built-in auth users hold the org FK; without this, destroy raises on the
17
+ # restrictive foreign key
18
+ has_many :users, dependent: :delete_all, extend: PREFIXED_FINDERS
19
+
20
+ validates :name, presence: true
21
+ validates :domain, presence: true, uniqueness: true
22
+ validates :subdomain, uniqueness: true, allow_blank: true
23
+
24
+ private
25
+
26
+ # Destroying an organization is a deliberate, total removal, so it clears
27
+ # the content that ContentType otherwise refuses to cascade, including
28
+ # rows behind the soft-delete scope.
29
+ def destroy_all_content
30
+ types = ContentType.with_discarded.where(organization_id: id)
31
+ # organization_id, not the type join: that column is what holds the FK
32
+ # Either column can hold the link if data has drifted, so cover both
33
+ documents = Document.with_discarded.where(organization_id: id)
34
+ .or(Document.with_discarded.where(content_type_id: types.select(:id)))
35
+ # Relations point at documents by foreign key, so incoming links have to
36
+ # go before their targets or the cascade order decides whether we crash.
37
+ Relation.where(target_document_id: documents.select(:id)).delete_all
38
+ documents.find_each(&:destroy!)
39
+ types.find_each(&:destroy!)
40
+ rescue ActiveRecord::RecordNotDestroyed => error
41
+ # Otherwise destroy returns false with an empty errors hash
42
+ errors.add(:base, "could not remove content: #{error.message}")
43
+ throw :abort
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ module RivetCms
2
+ class Relation < ApplicationRecord
3
+ include RevisionOwned
4
+
5
+ has_prefix_id :rel
6
+
7
+ belongs_to :field
8
+ belongs_to :target_document, class_name: "RivetCms::Document"
9
+
10
+ validate :target_in_same_organization
11
+
12
+ scope :ordered, -> { order(:position) }
13
+
14
+ private
15
+
16
+ def target_in_same_organization
17
+ return if target_document.nil? || owner_organization_id.nil?
18
+ return if target_document.organization_id == owner_organization_id
19
+
20
+ errors.add(:target_document, "must belong to the same organization")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,61 @@
1
+ module RivetCms
2
+ # Admin account for built-in authentication mode. Dormant when the host
3
+ # wires its own auth: nothing reads this table unless RivetCms.builtin_auth?
4
+ #
5
+ # CE has no roles: every authenticated user can do everything, so this model
6
+ # carries only identity and sign-in state. Role-based and per-record
7
+ # permissions are a Pro feature that installs its own policy through the
8
+ # can? seam and its own tables.
9
+ #
10
+ # Accounts are always created by an existing user (or the first-run setup
11
+ # screen); there is no self-registration. A user created without a password
12
+ # is pending: they finish through a signed set-password link and cannot sign
13
+ # in before that. Deactivation, never deletion: audit events and content
14
+ # attribution reference these rows.
15
+ class User < ApplicationRecord
16
+ include OrganizationScoped
17
+
18
+ has_prefix_id :usr
19
+
20
+ has_secure_password validations: false
21
+
22
+ before_validation { self.email = email.to_s.strip.downcase }
23
+
24
+ validates :name, presence: true
25
+ validates :email, presence: true,
26
+ format: { with: URI::MailTo::EMAIL_REGEXP },
27
+ uniqueness: { scope: :organization_id }
28
+ validates :password, length: { minimum: 8 }, allow_nil: true
29
+ validate :password_within_bcrypt_limit
30
+
31
+ scope :active, -> { where(active: true) }
32
+
33
+ # Invalidated automatically when the password changes (salt rotates)
34
+ generates_token_for :password_setup, expires_in: 3.days do
35
+ password_salt
36
+ end
37
+
38
+ def pending?
39
+ password_digest.nil?
40
+ end
41
+
42
+ def status
43
+ return "inactive" unless active?
44
+ return "pending" if pending?
45
+
46
+ "active"
47
+ end
48
+
49
+ def can_sign_in?
50
+ active? && !pending?
51
+ end
52
+
53
+ private
54
+
55
+ # BCrypt silently truncates at 72 bytes, which would make two long
56
+ # passwords sharing a prefix interchangeable; refuse instead
57
+ def password_within_bcrypt_limit
58
+ errors.add(:password, "is too long (maximum is 72 bytes)") if password && password.bytesize > 72
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,146 @@
1
+ module RivetCms
2
+ # Bulk-loads everything RevisionSerializer needs for a set of revisions in a
3
+ # constant number of queries, regardless of page size. Components cannot nest,
4
+ # so the owner tree is bounded: revision -> component instances -> values/relations.
5
+ # When populate_fields are given, target documents' revisions are loaded and
6
+ # preloaded the same way (one level deep).
7
+ class RevisionPreloader
8
+ def initialize(revisions, populate_fields: [], preview: false)
9
+ @revisions = Array(revisions).compact
10
+ @populate_field_ids = populate_fields.map(&:id).to_set
11
+ @preview = preview
12
+ @values = {}
13
+ @relations = {}
14
+ @instances = {}
15
+ @fields_by_content_type = {}
16
+ @fields_by_component = {}
17
+ @target_revision_by_document_id = {}
18
+ load_all
19
+ end
20
+
21
+ def document_for(revision)
22
+ revision.document
23
+ end
24
+
25
+ def fields_for_content_type(content_type_id)
26
+ @fields_by_content_type[content_type_id] || []
27
+ end
28
+
29
+ def fields_for_component(component_id)
30
+ @fields_by_component[component_id] || []
31
+ end
32
+
33
+ def value(owner, field)
34
+ @values.dig(owner_key(owner), field.id)
35
+ end
36
+
37
+ def relations(owner, field)
38
+ @relations.dig(owner_key(owner), field.id) || []
39
+ end
40
+
41
+ def component_instances(owner, field)
42
+ @instances.dig(owner_key(owner), field.id) || []
43
+ end
44
+
45
+ def target_revision(document_id)
46
+ @target_revision_by_document_id[document_id]
47
+ end
48
+
49
+ private
50
+
51
+ def owner_key(owner)
52
+ [ owner.class.name, owner.id ]
53
+ end
54
+
55
+ def load_all
56
+ return if @revisions.empty?
57
+
58
+ ActiveRecord::Associations::Preloader.new(records: @revisions, associations: { document: :content_type }).call
59
+ load_fields_for_content_types(@revisions.map { |revision| revision.document.content_type_id })
60
+ load_owned(@revisions)
61
+ load_populate_targets
62
+ end
63
+
64
+ def load_fields_for_content_types(content_type_ids)
65
+ missing = content_type_ids.uniq - @fields_by_content_type.keys
66
+ return if missing.empty?
67
+
68
+ Field.where(content_type_id: missing).ordered
69
+ .group_by(&:content_type_id)
70
+ .each { |ct_id, fields| @fields_by_content_type[ct_id] = fields }
71
+ missing.each { |ct_id| @fields_by_content_type[ct_id] ||= [] }
72
+ end
73
+
74
+ def load_owned(owners)
75
+ load_values_and_relations(owners)
76
+
77
+ instances = ComponentInstance
78
+ .where(owner_type: owners.first.class.name, owner_id: owners.map(&:id))
79
+ .order(:position).includes(:component).to_a
80
+ group_owned(instances, @instances)
81
+
82
+ component_ids = instances.map(&:component_id).uniq
83
+ if component_ids.any?
84
+ Field.where(component_id: component_ids).ordered
85
+ .group_by(&:component_id)
86
+ .each { |comp_id, fields| @fields_by_component[comp_id] = fields }
87
+ end
88
+
89
+ load_values_and_relations(instances) if instances.any?
90
+ end
91
+
92
+ def load_values_and_relations(owners)
93
+ owner_type = owners.first.class.name
94
+ owner_ids = owners.map(&:id)
95
+
96
+ values = ContentValue
97
+ .where(owner_type: owner_type, owner_id: owner_ids)
98
+ .includes(:field, media_asset: { file_attachment: :blob }).to_a
99
+ values.group_by { |value| [ value.owner_type, value.owner_id ] }.each do |key, group|
100
+ @values[key] = group.index_by(&:field_id)
101
+ end
102
+
103
+ relations = Relation
104
+ .where(owner_type: owner_type, owner_id: owner_ids)
105
+ .order(:position).includes(target_document: :content_type).to_a
106
+ group_owned(relations, @relations)
107
+ end
108
+
109
+ def group_owned(records, store)
110
+ records.group_by { |record| [ record.owner_type, record.owner_id ] }.each do |key, group|
111
+ store[key] = group.group_by(&:field_id)
112
+ end
113
+ end
114
+
115
+ def load_populate_targets
116
+ return if @populate_field_ids.empty?
117
+
118
+ targets = @revisions.flat_map { |revision|
119
+ (@relations[owner_key(revision)] || {})
120
+ .select { |field_id, _| @populate_field_ids.include?(field_id) }
121
+ .values.flatten.map(&:target_document)
122
+ }.compact.uniq # a target can be trashed, leaving the association nil
123
+
124
+ revision_id_by_document = targets.each_with_object({}) do |document, map|
125
+ revision_id = @preview ? (document.draft_revision_id || document.published_revision_id) : document.published_revision_id
126
+ map[document.id] = revision_id if revision_id
127
+ end
128
+ return if revision_id_by_document.empty?
129
+
130
+ target_revisions = DocumentRevision.where(id: revision_id_by_document.values).index_by(&:id)
131
+ documents_by_id = targets.index_by(&:id)
132
+
133
+ revision_id_by_document.each do |document_id, revision_id|
134
+ revision = target_revisions[revision_id]
135
+ next unless revision
136
+
137
+ revision.association(:document).target = documents_by_id[document_id]
138
+ @target_revision_by_document_id[document_id] = revision
139
+ end
140
+
141
+ loaded = @target_revision_by_document_id.values
142
+ load_fields_for_content_types(loaded.map { |revision| revision.document.content_type_id })
143
+ load_owned(loaded)
144
+ end
145
+ end
146
+ end