rivet_cms 0.1.2.pre → 0.3.0

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 +53 -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 +203 -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 +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -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,224 @@
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
+ enum :field_type, {
13
+ string: 0,
14
+ text: 1,
15
+ rich_text: 2,
16
+ markdown: 3,
17
+ integer: 4,
18
+ boolean: 5,
19
+ image: 6,
20
+ video: 7,
21
+ file: 8,
22
+ reference: 9,
23
+ component: 10,
24
+ date: 11,
25
+ datetime: 12,
26
+ decimal: 13,
27
+ enumeration: 14
28
+ }
29
+
30
+ enum :width, { full: "full", half: "half" }, prefix: true
31
+
32
+ validates :key, presence: true, format: { with: /\A[a-z][a-z0-9_]*\z/, message: "must be lowercase snake_case" }
33
+ validates :key, uniqueness: { scope: :content_type_id, conditions: -> { where(deleted_at: nil) } }, if: -> { content_type_id.present? }
34
+ validates :key, uniqueness: { scope: :component_id, conditions: -> { where(deleted_at: nil) } }, if: -> { component_id.present? }
35
+ validates :label, presence: true
36
+ validates :field_type, presence: true
37
+ validate :belongs_to_one_owner
38
+ validate :components_cannot_embed_components
39
+ validate :config_contents
40
+
41
+ before_validation :derive_key_from_label, on: :create
24
42
  before_create :set_default_position
25
-
26
- # Field type options for select
43
+ before_create :set_default_row
44
+
45
+ scope :ordered, -> { order(row: :asc, position: :asc) }
46
+
47
+ # Human-readable field type labels for UI
48
+ FIELD_TYPE_LABELS = {
49
+ "string" => "Short text",
50
+ "text" => "Long text",
51
+ "rich_text" => "Rich text",
52
+ "markdown" => "Markdown",
53
+ "integer" => "Number",
54
+ "decimal" => "Decimal",
55
+ "enumeration" => "Select",
56
+ "boolean" => "True/False",
57
+ "image" => "Image",
58
+ "video" => "Video",
59
+ "file" => "File",
60
+ "reference" => "Reference",
61
+ "component" => "Component",
62
+ "date" => "Date",
63
+ "datetime" => "Date & Time"
64
+ }.freeze
65
+
66
+ def field_type_label
67
+ FIELD_TYPE_LABELS[field_type] || field_type.humanize
68
+ end
69
+
27
70
  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
71
+ FIELD_TYPE_LABELS.map { |value, label| [ label, value ] }
72
+ end
73
+
74
+ def attachment?
75
+ image? || video? || file?
76
+ end
77
+
78
+ def self.reorder!(ordered_ids)
79
+ transaction do
80
+ ordered_ids.each_with_index do |id, index|
81
+ where(id: id).update_all(position: index)
82
+ end
50
83
  end
51
84
  end
52
-
53
- # Update positions for a set of fields
54
- def self.update_positions(ordered_ids)
55
- return if ordered_ids.blank?
56
-
85
+
86
+ # Update layout with row structure
87
+ # rows_config: array of arrays, each inner array contains numeric field
88
+ # IDs for that row, e.g. [[1], [2, 3], [4]]. Prefixed ids do not resolve
89
+ # through where(id:) and would silently match nothing.
90
+ def self.update_layout!(rows_config)
57
91
  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)
92
+ rows_config.each_with_index do |field_ids, row_index|
93
+ field_ids.each_with_index do |field_id, position_in_row|
94
+ where(id: field_id).update_all(row: row_index, position: position_in_row)
95
+ end
61
96
  end
62
97
  end
63
98
  end
64
-
65
- after_commit :invalidate_api_docs_cache
66
-
99
+
100
+ # Get the other field paired in the same row (if any)
101
+ def paired_field
102
+ return nil unless width_half?
103
+
104
+ sibling_fields.where(row: row, width: "half").where.not(id: id).first
105
+ end
106
+
107
+ def paired?
108
+ paired_field.present?
109
+ end
110
+
111
+ # Move this field to its own new row
112
+ def unpair!
113
+ return unless paired?
114
+
115
+ move_to_own_row!
116
+ end
117
+
118
+ # Move this field to its own new row
119
+ def move_to_own_row!
120
+ max_row = sibling_fields.maximum(:row) || 0
121
+ update!(row: max_row + 1, position: 0)
122
+ end
123
+
124
+ # Pair this field with another field on the same row
125
+ def pair_with!(other_field)
126
+ return unless width_half? && other_field.width_half?
127
+ return if paired? || other_field.paired?
128
+
129
+ other_field.update!(row: row, position: 1)
130
+ end
131
+
67
132
  private
68
-
133
+
134
+ def sibling_fields
135
+ scope = content_type_id.present? ? { content_type_id: content_type_id } : { component_id: component_id }
136
+ self.class.kept.where(scope)
137
+ end
138
+
139
+ def derive_key_from_label
140
+ return if key.present?
141
+
142
+ self.key = label.to_s.parameterize(separator: "_")
143
+ end
144
+
145
+ def components_cannot_embed_components
146
+ return unless component_id.present? && field_type == "component"
147
+
148
+ errors.add(:field_type, "component fields cannot be nested inside components")
149
+ end
150
+
151
+ def config_contents
152
+ validate_pattern_config
153
+ validate_choices_config
154
+ end
155
+
156
+ def validate_pattern_config
157
+ pattern = config&.dig("pattern")
158
+ return if pattern.blank?
159
+
160
+ unless field_type.in?(%w[string text])
161
+ return errors.add(:config, "pattern is only supported on text fields")
162
+ end
163
+
164
+ if pattern.length > SafePattern::MAX_LENGTH
165
+ errors.add(:config, "pattern must be #{SafePattern::MAX_LENGTH} characters or less")
166
+ elsif !SafePattern.valid?(pattern)
167
+ errors.add(:config, "pattern is not a valid regular expression")
168
+ end
169
+ end
170
+
171
+ def validate_choices_config
172
+ choices = config&.dig("choices")
173
+ return if choices.blank?
174
+ return errors.add(:config, "choices are only supported on select fields") unless enumeration?
175
+
176
+ unless choices.is_a?(Array) && choices.all? { |choice| choice.is_a?(::String) && choice.present? && choice.length <= 255 }
177
+ return errors.add(:config, "choices must be a list of short text values")
178
+ end
179
+
180
+ errors.add(:config, "choices must be unique") if choices.uniq.length != choices.length
181
+ errors.add(:config, "allows at most 100 choices") if choices.length > 100
182
+ end
183
+
184
+ def belongs_to_one_owner
185
+ has_content_type = content_type_id.present? || content_type.present?
186
+ has_component = component_id.present? || component.present?
187
+
188
+ if has_content_type && has_component
189
+ errors.add(:base, "Field cannot belong to both content_type and component")
190
+ elsif !has_content_type && !has_component
191
+ errors.add(:base, "Field must belong to either content_type or component")
192
+ end
193
+ end
194
+
69
195
  def set_default_position
70
- return if position.present?
71
-
72
- max_position = content_type.fields.maximum(:position) || 0
196
+ return if position.present? && position > 0
197
+
198
+ max_position = if content_type_id.present?
199
+ self.class.with_discarded.where(content_type_id: content_type_id).maximum(:position) || 0
200
+ elsif component_id.present?
201
+ self.class.with_discarded.where(component_id: component_id).maximum(:position) || 0
202
+ else
203
+ 0
204
+ end
205
+
73
206
  self.position = max_position + 1
74
207
  end
75
208
 
76
- def invalidate_api_docs_cache
77
- if content_type
78
- Rails.cache.delete("rivet_cms_api_docs")
209
+ def set_default_row
210
+ # Always set a new row for new fields (don't skip if row is 0)
211
+ return if persisted? && row.present?
212
+
213
+ max_row = if content_type_id.present?
214
+ self.class.with_discarded.where(content_type_id: content_type_id).maximum(:row) || -1
215
+ elsif component_id.present?
216
+ self.class.with_discarded.where(component_id: component_id).maximum(:row) || -1
217
+ else
218
+ -1
79
219
  end
220
+
221
+ self.row = max_row + 1
80
222
  end
81
223
  end
82
- end
224
+ 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