postnhost 0.1.1 → 0.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +11 -0
  4. data/app/assets/builds/postnhost/application.css +1 -1
  5. data/app/controllers/postnhost/api/base_controller.rb +29 -0
  6. data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
  7. data/app/controllers/postnhost/api/v1/mcp_controller.rb +49 -0
  8. data/app/controllers/postnhost/api_tokens_controller.rb +34 -0
  9. data/app/controllers/postnhost/articles/translations_controller.rb +2 -9
  10. data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
  11. data/app/controllers/postnhost/management_preview_controller.rb +17 -0
  12. data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
  13. data/app/models/postnhost/api_token.rb +26 -0
  14. data/app/models/postnhost/user.rb +2 -0
  15. data/app/services/postnhost/api/v1/openapi.rb +27 -0
  16. data/app/services/postnhost/management/catalog.rb +151 -0
  17. data/app/services/postnhost/management/command.rb +174 -0
  18. data/app/services/postnhost/management/content_actions.rb +75 -0
  19. data/app/services/postnhost/management/input.rb +49 -0
  20. data/app/services/postnhost/management/mcp.rb +54 -0
  21. data/app/services/postnhost/management/preview.rb +25 -0
  22. data/app/services/postnhost/management/resources.rb +30 -0
  23. data/app/services/postnhost/management/schema_fields.rb +25 -0
  24. data/app/services/postnhost/management/serializer.rb +40 -0
  25. data/app/services/postnhost/management/settings_actions.rb +64 -0
  26. data/app/services/postnhost/management/upload.rb +18 -0
  27. data/app/services/postnhost/translation/enqueue.rb +59 -0
  28. data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
  29. data/app/views/postnhost/api_tokens/index.html.erb +92 -0
  30. data/config/routes.rb +12 -0
  31. data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
  32. data/lib/postnhost/version.rb +1 -1
  33. metadata +22 -1
@@ -0,0 +1,29 @@
1
+ module Postnhost
2
+ module Api
3
+ # Bearer-only boundary intentionally excludes host session callbacks.
4
+ # rubocop:disable-next Rails/ApplicationController
5
+ class BaseController < ActionController::Base
6
+ skip_forgery_protection
7
+ before_action :prevent_caching
8
+ before_action :authenticate_token!
9
+
10
+ private
11
+
12
+ attr_reader :api_user
13
+
14
+ def authenticate_token!
15
+ scheme, secret = request.authorization.to_s.split(" ", 2)
16
+ token = ApiToken.authenticate(secret) if scheme&.casecmp?("Bearer")
17
+ @api_user = token&.user
18
+ return if api_user
19
+
20
+ response.set_header("WWW-Authenticate", 'Bearer realm="PostnHost"')
21
+ render json: { errors: ["A valid bearer token is required"] }, status: :unauthorized
22
+ end
23
+
24
+ def prevent_caching
25
+ response.set_header("Cache-Control", "no-store")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ class CommandsController < Api::BaseController
5
+ def index
6
+ render json: { commands: Management::Catalog.tools }
7
+ end
8
+
9
+ def schema
10
+ server = request.base_url + api_v1_commands_path.delete_suffix("/commands")
11
+ render json: Openapi.document(server:)
12
+ end
13
+
14
+ def create
15
+ input = params.fetch(:arguments, ActionController::Parameters.new)
16
+ unless input.is_a?(ActionController::Parameters)
17
+ render json: { errors: ["arguments must be an object"] }, status: :bad_request
18
+ return
19
+ end
20
+ arguments = input.to_unsafe_h
21
+ result = Management::Command.call(name: params[:command], arguments:, user: api_user, script_name: request.script_name)
22
+ render json: { data: result.value, errors: result.errors }, status: result.status
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ class McpController < Api::BaseController
5
+ before_action :validate_transport!
6
+
7
+ def create
8
+ message = JSON.parse(request.raw_post)
9
+ unless message.is_a?(Hash)
10
+ render json: { jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Invalid Request" } }, status: :bad_request
11
+ return
12
+ end
13
+ result = Management::Mcp.new(user: api_user, script_name: request.script_name).call(message)
14
+ result ? render(json: result) : head(:accepted)
15
+ rescue JSON::ParserError
16
+ render json: { jsonrpc: "2.0", id: nil, error: { code: -32_700, message: "Parse error" } }, status: :bad_request
17
+ end
18
+
19
+ def unsupported
20
+ response.set_header("Allow", "POST")
21
+ head :method_not_allowed
22
+ end
23
+
24
+ private
25
+
26
+ def validate_transport!
27
+ origin = request.headers["Origin"]
28
+ if origin.present? && origin != request.base_url
29
+ head :forbidden
30
+ return
31
+ end
32
+ version = request.headers["MCP-Protocol-Version"]
33
+ if version.present? && Management::Mcp::VERSIONS.exclude?(version)
34
+ head :bad_request
35
+ return
36
+ end
37
+ return unless request.post?
38
+
39
+ unless request.media_type == "application/json"
40
+ head :unsupported_media_type
41
+ return
42
+ end
43
+ accept = request.headers["Accept"].to_s
44
+ head :not_acceptable unless accept.include?("application/json") && accept.include?("text/event-stream")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,34 @@
1
+ module Postnhost
2
+ class ApiTokensController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :prevent_caching
5
+ layout "postnhost/product"
6
+
7
+ def index
8
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
9
+ end
10
+
11
+ def create
12
+ @api_token, @secret = ApiToken.issue!(user: current_user, **token_params.to_h.symbolize_keys)
13
+ @api_tokens = current_user.api_tokens.order(created_at: :desc)
14
+ render :index
15
+ rescue ActiveRecord::RecordInvalid => e
16
+ redirect_to api_tokens_path, alert: e.record.errors.full_messages.to_sentence
17
+ end
18
+
19
+ def destroy
20
+ current_user.api_tokens.find(params[:id]).revoke!
21
+ redirect_to api_tokens_path, notice: "API token revoked."
22
+ end
23
+
24
+ private
25
+
26
+ def prevent_caching
27
+ response.set_header("Cache-Control", "no-store")
28
+ end
29
+
30
+ def token_params
31
+ params.require(:api_token).permit(:name)
32
+ end
33
+ end
34
+ end
@@ -6,15 +6,8 @@ module Postnhost
6
6
  def new; end
7
7
 
8
8
  def create
9
- language_ids = params[:language_ids] || []
10
-
11
- language_ids.each do |language_id|
12
- Postnhost::Translation::ArticleVariantJob.perform_later(@article.id, language_id.to_i)
13
-
14
- @article.article_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
15
- end
16
-
17
- redirect_to article_variants_path(@article)
9
+ result = Postnhost::Translation::Enqueue.call(record: @article, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
10
+ redirect_to article_variants_path(@article), **(result.success? ? {} : { alert: result.errors.to_sentence })
18
11
  end
19
12
 
20
13
  private
@@ -4,15 +4,8 @@ module Postnhost
4
4
  before_action :load_category
5
5
 
6
6
  def create
7
- language_ids = params[:language_ids] || []
8
-
9
- language_ids.each do |language_id|
10
- Postnhost::Translation::CategoryVariantJob.perform_later(@category.id, language_id.to_i)
11
-
12
- @category.category_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
13
- end
14
-
15
- redirect_to category_variants_path(@category)
7
+ result = Postnhost::Translation::Enqueue.call(record: @category, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
8
+ redirect_to category_variants_path(@category), **(result.success? ? {} : { alert: result.errors.to_sentence })
16
9
  end
17
10
 
18
11
  private
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ # Used only by Management::Preview for an authenticated in-process render.
3
+ # There are no routes to this controller.
4
+ class ManagementPreviewController < Public::ArticlesController
5
+ helper_method :postnhost
6
+
7
+ private
8
+
9
+ def postnhost
10
+ @postnhost ||= ActionDispatch::Routing::RoutesProxy.new(Engine.routes, self, Engine.routes.url_helpers)
11
+ end
12
+
13
+ def current_user
14
+ request.env.fetch("postnhost.management_user")
15
+ end
16
+ end
17
+ end
@@ -6,15 +6,8 @@ module Postnhost
6
6
  def new; end
7
7
 
8
8
  def create
9
- language_ids = params[:language_ids] || []
10
-
11
- language_ids.each do |language_id|
12
- Postnhost::Translation::PageVariantJob.perform_later(@page.id, language_id.to_i)
13
-
14
- @page.page_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
15
- end
16
-
17
- redirect_to page_variants_path(@page)
9
+ result = Postnhost::Translation::Enqueue.call(record: @page, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
10
+ redirect_to page_variants_path(@page), **(result.success? ? {} : { alert: result.errors.to_sentence })
18
11
  end
19
12
 
20
13
  private
@@ -0,0 +1,26 @@
1
+ require "digest"
2
+ require "securerandom"
3
+
4
+ module Postnhost
5
+ class ApiToken < ApplicationRecord
6
+ belongs_to :user, class_name: "Postnhost::User"
7
+ validates :name, :token_digest, presence: true
8
+ validates :token_digest, uniqueness: true
9
+
10
+ def self.issue!(user:, name:)
11
+ secret = "pnh_#{SecureRandom.urlsafe_base64(32)}"
12
+ token = create!(user:, name:, token_digest: Digest::SHA256.hexdigest(secret))
13
+ [token, secret]
14
+ end
15
+
16
+ def self.authenticate(secret)
17
+ return if secret.blank?
18
+
19
+ find_by(token_digest: Digest::SHA256.hexdigest(secret), revoked_at: nil)
20
+ end
21
+
22
+ def revoke!
23
+ update!(revoked_at: Time.current)
24
+ end
25
+ end
26
+ end
@@ -5,6 +5,8 @@ module Postnhost
5
5
 
6
6
  self.table_name = "postnhost_users"
7
7
 
8
+ has_many :api_tokens, class_name: "Postnhost::ApiToken", dependent: :destroy
9
+
8
10
  has_secure_password
9
11
 
10
12
  mount_uploader :avatar_file, Postnhost::AvatarUploader
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Api
3
+ module V1
4
+ module Openapi
5
+ def self.document(server:)
6
+ { openapi: "3.1.0", info: { title: "PostnHost CMS management API", version: "1" }, servers: [{ url: server }],
7
+ security: [{ bearerAuth: [] }], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } },
8
+ paths: Management::Catalog.tools.to_h { |tool| ["/commands/#{tool[:name]}", { post: operation(tool) }] } }
9
+ end
10
+
11
+ def self.operation(tool)
12
+ { operationId: tool[:name], summary: tool[:description],
13
+ requestBody: { required: true, content: { "application/json" => { schema: { type: "object", required: ["arguments"], properties: { arguments: tool[:inputSchema] } } } } },
14
+ responses: {
15
+ "200" => { description: "Command completed. JSON contains data and errors." },
16
+ "202" => { description: "Translation queued. Poll the variant's generating field." },
17
+ "400" => { description: "Invalid arguments." }, "401" => { description: "Invalid bearer token." },
18
+ "404" => { description: "Record not found in the requested scope." },
19
+ "409" => { description: "Conflicting data or protected action." },
20
+ "422" => { description: "Validation or publication failure. Bulk results contain per-record failures." },
21
+ "503" => { description: "Translation queue unavailable. Data identifies queued work and failed_language_ids to retry." }
22
+ } }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,151 @@
1
+ module Postnhost
2
+ module Management
3
+ module Catalog
4
+ INTEGER = { type: "integer", minimum: 1 }.freeze
5
+ IDS = { type: "array", items: INTEGER, minItems: 1, maxItems: 100, uniqueItems: true }.freeze
6
+ FILE = { type: "object", properties: { filename: { type: "string" }, content_type: { type: "string" }, base64: { type: "string" } }, required: %w[filename content_type base64], additionalProperties: false }.freeze
7
+ READ_ACTIONS = %w[list get versions version_preview preview get_navigation get_translations].freeze
8
+
9
+ def self.tools
10
+ @tools ||= freeze_schema(build_tools)
11
+ end
12
+
13
+ def self.build_tools
14
+ Resources::DEFINITIONS.flat_map do |resource, definition|
15
+ actions = definition[:singleton] ? %w[get update] : %w[list get create update delete]
16
+ actions.delete("update") if resource == "api_tokens"
17
+ actions += %w[publish unpublish] if %w[articles pages article_variants page_variants].include?(resource)
18
+ actions += %w[translate] if %w[articles pages categories].include?(resource)
19
+ actions += %w[bulk_publish bulk_unpublish bulk_delete] if %w[article_variants page_variants].include?(resource)
20
+ actions += %w[versions version_preview restore preview upload_image upload_cover remove_cover] if resource == "articles"
21
+ actions += %w[preview upload_image] if resource == "pages"
22
+ actions += %w[get_navigation replace_navigation get_translations update_translations upload_logo upload_og_image remove_logo remove_og_image] if resource == "settings"
23
+ actions += %w[preview] if resource == "template"
24
+ actions += %w[upload_avatar] if resource == "users"
25
+ actions.map { |action| tool(resource, action, definition) }
26
+ end
27
+ end
28
+
29
+ def self.fetch(name)
30
+ @tools_by_name ||= tools.index_by { |tool| tool[:name] }.freeze
31
+ @tools_by_name.fetch(name) { raise ArgumentError, "Unknown command: #{name}" }
32
+ end
33
+
34
+ def self.tool(resource, action, definition)
35
+ read = READ_ACTIONS.include?(action)
36
+ detail = if action == "list"
37
+ "Returns compact summaries; use get for full content, relationships and draft comparisons."
38
+ elsif read
39
+ "Reads CMS draft data."
40
+ else
41
+ "Uses CMS validations and publication workflows."
42
+ end
43
+ { name: "#{resource}_#{action}", description: "#{action.tr('_', ' ').capitalize} #{resource.tr('_', ' ')}. #{detail}",
44
+ inputSchema: schema(resource, action, definition),
45
+ annotations: { readOnlyHint: read, destructiveHint: !read, idempotentHint: read || %w[update unpublish delete].include?(action) || action.start_with?("remove_"), openWorldHint: action == "translate" } }
46
+ end
47
+
48
+ def self.schema(resource, action, definition)
49
+ properties = {}
50
+ required = []
51
+ if definition[:parent]
52
+ properties["parent_id"] = INTEGER
53
+ required << "parent_id"
54
+ end
55
+ unless definition[:singleton] || %w[list create translate].include?(action) || action.start_with?("bulk_")
56
+ properties["id"] = INTEGER
57
+ required << "id"
58
+ end
59
+ case action
60
+ when "list"
61
+ properties["page"] = INTEGER
62
+ properties["limit"] = { type: "integer", minimum: 1, maximum: 100 }
63
+ properties["language_id"] = INTEGER if definition[:fields].include?("language_id")
64
+ properties.merge!("status" => { type: "string", enum: Article::STATUSES }, "sort" => { type: "string", enum: Article::SORT_OPTIONS.keys }, "mine" => { type: "boolean" }, "category_id" => INTEGER, "user_id" => INTEGER) if resource == "articles"
65
+ when "preview"
66
+ properties["name"] = { type: "string", enum: Template::NAMES } if resource == "template"
67
+ required << "name" if resource == "template"
68
+ when "create", "update"
69
+ properties["attributes"] = { type: "object", properties: definition[:fields].index_with { |field| field_schema(field, resource) }, additionalProperties: false }
70
+ required << "attributes"
71
+ when "translate"
72
+ properties["id"] = INTEGER
73
+ properties["language_ids"] = IDS
74
+ required += %w[id language_ids]
75
+ when "bulk_publish", "bulk_unpublish", "bulk_delete"
76
+ properties["ids"] = IDS
77
+ required << "ids"
78
+ when "restore", "version_preview"
79
+ properties["version_id"] = INTEGER
80
+ required << "version_id"
81
+ when "replace_navigation"
82
+ properties["tree"] = { type: "object", properties: { header: { type: "array", items: navigation_item_schema, maxItems: 100 }, footer: { type: "array", items: navigation_item_schema, maxItems: 100 } }, required: %w[header footer], additionalProperties: false }
83
+ required << "tree"
84
+ properties["locale"] = { type: "string" }
85
+ required << "locale"
86
+ when "get_navigation", "get_translations", "update_translations"
87
+ properties["locale"] = { type: "string" }
88
+ required << "locale"
89
+ if action == "update_translations"
90
+ properties["translations"] = { type: "object", additionalProperties: { type: "string" } }
91
+ required << "translations"
92
+ end
93
+ else
94
+ if action.start_with?("upload_")
95
+ properties["file"] = FILE
96
+ required << "file"
97
+ end
98
+ end
99
+ { type: "object", properties:, required:, additionalProperties: false }
100
+ end
101
+
102
+ def self.navigation_item_schema(depth = 0)
103
+ properties = {
104
+ id: INTEGER, kind: { type: "string", enum: NavigationItem.kinds.keys },
105
+ target_kind: { type: %w[string null], enum: NavigationItem.target_kinds.keys + [nil] },
106
+ target_id: { type: %w[integer null] }, target_slug: { type: %w[string null] },
107
+ url: { type: %w[string null] }, label: { type: %w[string null] }, nofollow: { type: "boolean" }
108
+ }
109
+ properties[:children] = { type: "array", items: navigation_item_schema(depth + 1), maxItems: 100 } if depth < 2
110
+ { type: "object", properties:, required: ["kind"], additionalProperties: false }
111
+ end
112
+
113
+ # This module is reloadable: schemas and their name index disappear with it.
114
+ # Freeze nested values so one consumer cannot modify another client's contract.
115
+ def self.freeze_schema(value)
116
+ case value
117
+ when Hash then value.each do |key, child|
118
+ freeze_schema(key)
119
+ freeze_schema(child)
120
+ end
121
+ when Array then value.each { |child| freeze_schema(child) }
122
+ end
123
+ value.freeze
124
+ end
125
+ private_class_method :build_tools, :freeze_schema
126
+
127
+ def self.field_schema(field, resource)
128
+ return { type: "string", enum: Template::NAMES } if resource == "template" && field == "name"
129
+ return { type: "string", enum: SiteScript::PLACEMENTS } if field == "placement"
130
+
131
+ return { type: %w[string null], format: "date-time" } if field == "scheduled_at"
132
+
133
+ if field.end_with?("_ids")
134
+ { type: "array", items: INTEGER, maxItems: 100, uniqueItems: true }
135
+ elsif field.end_with?("_id") || field == "public_page_size"
136
+ { type: %w[integer null] }
137
+ elsif %w[default top_pick use_excerpt_as_meta_description author_pages_enabled search_enabled show_powered_by use_auto_header_navigation use_auto_footer_navigation].include?(field)
138
+ { type: "boolean" }
139
+ elsif field == "schema_settings"
140
+ SchemaFields.settings
141
+ elsif field == "schema_profile"
142
+ SchemaFields.profile
143
+ elsif field == "schema_locale_overrides"
144
+ SchemaFields.overrides(resource)
145
+ else
146
+ { type: %w[string null] }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,174 @@
1
+ module Postnhost
2
+ module Management
3
+ class Command < BaseService
4
+ def initialize(name:, arguments:, user:, script_name: "")
5
+ @name = name
6
+ @arguments = arguments.deep_stringify_keys
7
+ @user = user
8
+ @script_name = script_name
9
+ end
10
+
11
+ def call
12
+ return failure("Authentication required", status: :unauthorized) unless user
13
+
14
+ tool = Catalog.fetch(name)
15
+ Input.validate!(arguments, tool[:inputSchema])
16
+ resource = Resources::DEFINITIONS.keys.find { |key| name.start_with?("#{key}_") }
17
+ action = name.delete_prefix("#{resource}_")
18
+ result = PaperTrail.request(whodunnit: user.id.to_s) do
19
+ Time.use_zone(Setting.current.effective_timezone_name) { execute(resource, action) }
20
+ end
21
+ result.is_a?(ServiceResult) ? serialize_result(result) : success(Serializer.call(result), status: :ok)
22
+ rescue ActiveRecord::RecordNotFound
23
+ failure("Record not found", status: :not_found)
24
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
25
+ failure(e.record.errors.full_messages)
26
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::InvalidForeignKey
27
+ failure("Record conflicts with existing or referenced data", status: :conflict)
28
+ rescue ActiveRecord::StaleObjectError
29
+ failure("Record changed; reload and retry", status: :conflict)
30
+ rescue ArgumentError, TypeError => e
31
+ failure(e.message, status: :bad_request)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :name, :arguments, :user, :script_name
37
+
38
+ def serialize_result(result)
39
+ ServiceResult.new(value: Serializer.call(result.value), errors: result.errors, status: result.status || :ok)
40
+ end
41
+
42
+ def execute(resource, action)
43
+ definition = Resources::DEFINITIONS.fetch(resource)
44
+ scope = scope_for(resource, definition)
45
+ return list(scope, resource) if action == "list"
46
+ return create(scope, resource, definition) if action == "create"
47
+ return ContentActions.call(resource:, action:, scope:, arguments:) if action.start_with?("bulk_")
48
+
49
+ scope = scope.select(:id) if action == "versions"
50
+ record = definition[:singleton] ? Resources.model(resource).current : scope.find(arguments.fetch("id"))
51
+ case action
52
+ when "get" then record
53
+ when "update" then update(record)
54
+ when "delete" then delete(record)
55
+ when "preview"
56
+ if resource == "template"
57
+ Preview.call(user:, template: arguments.fetch("name"), script_name:)
58
+ elsif resource == "articles"
59
+ Preview.call(user:, record:, script_name:).merge(draft: record)
60
+ else
61
+ ContentActions.call(resource:, action:, scope:, record:, arguments:)
62
+ end
63
+ else ContentActions.call(resource:, action:, scope:, record:, arguments:)
64
+ end
65
+ end
66
+
67
+ def scope_for(resource, definition)
68
+ if definition[:parent]
69
+ parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
70
+ parent.public_send(resource)
71
+ elsif resource == "api_tokens"
72
+ user.api_tokens
73
+ elsif resource == "site_scripts"
74
+ Setting.current.site_scripts
75
+ else
76
+ Resources.model(resource).all
77
+ end
78
+ end
79
+
80
+ def list(scope, resource)
81
+ scope = scope.where(language_id: arguments["language_id"]) if arguments["language_id"] && scope.klass.column_names.include?("language_id")
82
+ if resource == "articles"
83
+ scope = scope.where(user_id: user.id) if arguments["mine"]
84
+ scope = scope.joins(:article_categories).where(postnhost_article_categories: { category_id: arguments["category_id"] }) if arguments["category_id"]
85
+ scope = scope.where(user_id: arguments["user_id"]) if arguments["user_id"]
86
+ scope = scope.with_status(arguments["status"]).sorted_by(arguments["sort"])
87
+ else
88
+ scope = scope.order(:id)
89
+ end
90
+ limit = arguments.fetch("limit", 25)
91
+ page = arguments.fetch("page", 1)
92
+ records = summary_scope(scope, resource).limit(limit).offset((page - 1) * limit)
93
+ result = { records: records.map { |record| Serializer.summary(record) }, total: scope.count, page:, limit: }
94
+ result[:static_pages] = static_page_slugs if resource == "pages"
95
+ result
96
+ end
97
+
98
+ def summary_scope(scope, resource)
99
+ table = scope.klass.arel_table
100
+ fields = Serializer::SUMMARY_FIELDS & scope.klass.column_names
101
+ records = scope.select(*fields.map { |field| table[field] })
102
+ snapshot = Resources::DEFINITIONS.fetch(resource)[:snapshot]
103
+ return records unless snapshot
104
+
105
+ association = scope.klass.reflect_on_association(snapshot)
106
+ snapshot_table = association.klass.arel_table
107
+ snapshot_id = association.klass.where(snapshot_table[association.foreign_key].eq(table[:id])).select(:id).limit(1)
108
+ records.select(snapshot_id.arel.as("management_snapshot_id"))
109
+ end
110
+
111
+ def static_page_slugs
112
+ [Rails.root, Engine.root].flat_map { |root| Dir[root.join("app/views/postnhost/static_pages/*.html.erb").to_s] }
113
+ .map { |path| File.basename(path, ".html.erb") }.uniq.sort
114
+ end
115
+
116
+ def create(scope, resource, definition)
117
+ attrs = arguments.fetch("attributes")
118
+ if resource == "api_tokens"
119
+ token, secret = ApiToken.issue!(user:, **attrs.symbolize_keys)
120
+ return { token:, secret: }
121
+ end
122
+ if %w[articles pages].include?(resource)
123
+ attrs = { "language_id" => Language.blog_default&.id }.merge(attrs).merge("user_id" => user.id)
124
+ elsif definition[:parent] && resource != "category_variants"
125
+ parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
126
+ copied = parent.attributes.slice(*definition[:fields]).except("language_id")
127
+ attrs = copied.merge(attrs)
128
+ end
129
+ validate_schema_locales!(attrs)
130
+ record = scope.create!(attrs)
131
+ record.sync_publication_schedule! if record.is_a?(Article)
132
+ record
133
+ end
134
+
135
+ def update(record)
136
+ raise ArgumentError, "Translation is still generating" if record.respond_to?(:generating?) && record.generating?
137
+
138
+ attrs = arguments.fetch("attributes")
139
+ attrs = attrs.except("password", "password_confirmation") if record.is_a?(User) && attrs["password"].blank?
140
+ validate_schema_locales!(attrs)
141
+ record.update!(attrs)
142
+ record.sync_publication_schedule! if record.is_a?(Article)
143
+ record
144
+ end
145
+
146
+ def validate_schema_locales!(attributes)
147
+ return unless attributes["schema_locale_overrides"]
148
+
149
+ unknown = attributes["schema_locale_overrides"].keys - Language.pluck(:html_lang)
150
+ raise ArgumentError, "Schema locales must belong to configured languages" if unknown.any?
151
+ end
152
+
153
+ def delete(record)
154
+ if record.is_a?(ApiToken)
155
+ record.revoke!
156
+ return { revoked: true, id: record.id }
157
+ end
158
+ if record.is_a?(User)
159
+ return failure("You cannot remove your own account", status: :conflict) if record == user
160
+ return failure("Author owns pages; reassign or remove them first", status: :conflict) if record.pages.exists?
161
+
162
+ User.transaction do
163
+ Article.where(user_id: record.id).update_all(user_id: nil, updated_at: Time.current)
164
+ record.article_authors.delete_all
165
+ record.destroy!
166
+ end
167
+ else
168
+ record.destroy!
169
+ end
170
+ { deleted: true, id: record.id }
171
+ end
172
+ end
173
+ end
174
+ end