ecoportal-api-graphql 2.2.0 → 3.0.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +448 -31
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +1 -1
  8. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +1 -1
  9. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  10. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  11. data/lib/ecoportal/api/graphql/base.rb +1 -0
  12. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  13. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  14. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  15. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +8 -3
  16. data/lib/ecoportal/api/graphql/fragment/permissions.rb +0 -2
  17. data/lib/ecoportal/api/graphql/input/page/update.rb +109 -6
  18. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  19. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +1 -1
  20. data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +1 -1
  21. data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
  22. data/lib/ecoportal/api/graphql/input/workflow_command.rb +2 -0
  23. data/lib/ecoportal/api/graphql/logic/base_query.rb +1 -1
  24. data/lib/ecoportal/api/graphql/model/permissions.rb +6 -0
  25. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  26. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  27. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  28. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  29. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  30. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  31. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  32. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  33. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  34. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  35. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  36. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  37. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  38. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  39. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  40. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  41. data/lib/ecoportal/api/graphql/model.rb +1 -0
  42. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  43. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  44. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  45. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  46. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  47. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  48. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  49. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  50. data/lib/ecoportal/api/graphql/query/pages.rb +1 -1
  51. data/lib/ecoportal/api/graphql/query/permissions.rb +1 -1
  52. data/lib/ecoportal/api/graphql_version.rb +1 -1
  53. metadata +36 -2
  54. data/README.md +0 -24
@@ -0,0 +1,236 @@
1
+ require 'securerandom'
2
+
3
+ module Ecoportal
4
+ module API
5
+ class GraphQL
6
+ module Model
7
+ module Template
8
+ # Ports a downstream script repo's staged `-template-force-install` install (sections ->
9
+ # re-read -> fields+options -> re-read -> forces -> re-read -> helpers+bindings) into
10
+ # a GENERIC, four-phase executor that works for ANY `Instance`, any number of
11
+ # stages/sections/fields — not one template's shape. Each phase is built FRESH from
12
+ # the CURRENT tree (`CommandSynthesis`'s phase-scoped methods), so a node resolved by
13
+ # an earlier phase (`Node#resolve!`) is addressed by its real id — never its stale
14
+ # placeholder — the moment the next phase's commands are synthesised (`Node#ref`
15
+ # itself does the upgrade; nothing here has to rewrite a command hash by hand).
16
+ #
17
+ # Sends through `Builder::Template#update` (`updatePageTemplate`) — NEVER
18
+ # `executeWorkflowCommands` — per `18_template_editor_save_path.md` section D.4/B.3's
19
+ # own recommendation (the SAME safety invariants the template editor enforces should
20
+ # apply to bulk/automated writes). Every call carries a fresh `clientMutationId` for
21
+ # tracing/correlation ONLY — the server does NOT dedupe on it (confirmed: neither
22
+ # `UpdateForm` nor `ExecuteCommandsForm` reference it in
23
+ # `18_template_editor_save_path.md` section B).
24
+ class StagedExecutor
25
+ DEFAULT_CHUNK_SIZE = 200
26
+
27
+ PHASES = %i[stage_phase section_phase field_phase force_phase helper_binding_phase].freeze
28
+
29
+ # Placeholder prefixes that MUST already be resolved to real ids by the time this
30
+ # phase's commands are built — a leftover token here means an earlier phase's
31
+ # resolution silently failed to cover this node, and sending anyway would either
32
+ # get rejected server-side or (worse) silently misfire. See `#guard_resolved!`.
33
+ REQUIRES_RESOLVED = {
34
+ section_phase: %w[stg],
35
+ field_phase: %w[stg sec],
36
+ force_phase: %w[stg],
37
+ helper_binding_phase: %w[stg sec frc fld]
38
+ }.freeze
39
+
40
+ Plan = Struct.new(:stages, keyword_init: true)
41
+ Result = Struct.new(:ok, :patch_ver, :stages, :error, keyword_init: true)
42
+
43
+ def initialize(instance, client:, chunk_size: DEFAULT_CHUNK_SIZE)
44
+ @instance = instance
45
+ @client = client
46
+ @chunk_size = chunk_size
47
+ end
48
+
49
+ # SIMULATE — no call is made. Returns the plan every phase WOULD send: command
50
+ # counts, chunk sizes, and every placeholder token appearing in that phase (a
51
+ # caller can eyeball this before ever touching the server).
52
+ def plan
53
+ synth = CommandSynthesis.new(instance)
54
+ Plan.new(stages: PHASES.map { |phase| phase_plan(synth, phase) })
55
+ end
56
+
57
+ # EXECUTE — actually sends the staged batches. Raises ArgumentError up front if no
58
+ # client was given (a `simulate: false` call with no client is a caller mistake,
59
+ # not a runtime condition to swallow).
60
+ def execute!
61
+ raise ArgumentError, 'StagedExecutor#execute! requires a client (got nil)' if client.nil?
62
+
63
+ patch_ver = instance.patch_ver
64
+ report = []
65
+
66
+ PHASES.each do |phase|
67
+ outcome = execute_phase(phase, patch_ver)
68
+ report << outcome
69
+ return abort(report, outcome[:error]) unless outcome[:ok]
70
+
71
+ patch_ver = outcome[:patch_ver]
72
+ end
73
+
74
+ Result.new(ok: true, patch_ver: patch_ver, stages: report, error: nil)
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :instance, :client, :chunk_size
80
+
81
+ def phase_plan(synth, phase)
82
+ commands = synth.public_send(phase)
83
+ chunks = CommandGrouper.chunk(commands, chunk_size)
84
+ { phase: phase, command_count: commands.size, chunk_sizes: chunks.map(&:size),
85
+ placeholders: CommandGrouper.tokens_in(commands) }
86
+ end
87
+
88
+ def execute_phase(phase, patch_ver)
89
+ commands = CommandSynthesis.new(instance).public_send(phase)
90
+ return { phase: phase, ok: true, patch_ver: patch_ver, sent: 0 } if commands.empty?
91
+
92
+ guard_resolved!(phase, commands)
93
+
94
+ chunks = CommandGrouper.chunk(commands, chunk_size)
95
+ chunks.each do |chunk|
96
+ response = send_chunk(chunk, patch_ver)
97
+ return { phase: phase, ok: false, patch_ver: patch_ver, error: response[:error] } unless response[:ok]
98
+
99
+ patch_ver = response[:patch_ver]
100
+ end
101
+
102
+ resolve_phase!(phase)
103
+ { phase: phase, ok: true, patch_ver: patch_ver, sent: commands.size }
104
+ end
105
+
106
+ def abort(report, error)
107
+ Result.new(ok: false, patch_ver: report.last[:patch_ver], stages: report, error: error)
108
+ end
109
+
110
+ # Refuses to send a batch that still references a placeholder an EARLIER phase
111
+ # should already have resolved — see `REQUIRES_RESOLVED`'s own doc.
112
+ def guard_resolved!(phase, commands)
113
+ prefixes = REQUIRES_RESOLVED[phase]
114
+ return if prefixes.nil? || prefixes.empty?
115
+
116
+ stale = CommandGrouper.tokens_in(commands).select { |tok| prefixes.any? { |p| tok.start_with?("ph_#{p}_") } }
117
+ return if stale.empty?
118
+
119
+ raise "StagedExecutor: refusing to send #{phase} — it still references " \
120
+ "#{stale.size} unresolved placeholder(s) from an earlier phase: #{stale.join(', ')}. " \
121
+ 'An earlier phase\'s resolution step did not cover every new node it created.'
122
+ end
123
+
124
+ def send_chunk(chunk, patch_ver)
125
+ payload = builder.update(instance, commands: chunk, patch_ver: patch_ver,
126
+ client_mutation_id: "tmpl-model-#{SecureRandom.hex(6)}")
127
+ return { ok: false, error: 'no payload returned' } if payload.nil?
128
+ return { ok: false, error: payload.error_doc } if payload.error?
129
+
130
+ { ok: true, patch_ver: payload.item.patchVer }
131
+ end
132
+
133
+ def builder
134
+ @builder ||= client.template
135
+ end
136
+
137
+ # --- between-phase resolution -------------------------------------------
138
+
139
+ def resolve_phase!(phase)
140
+ case phase
141
+ when :stage_phase then resolve_stages! if instance.stages.any?(&:new?)
142
+ when :section_phase then resolve_sections! if instance.sections.any?(&:new?)
143
+ when :field_phase then resolve_fields! if instance.fields.any?(&:new?)
144
+ when :force_phase then resolve_forces! if instance.forces.any?(&:new?)
145
+ end
146
+ end
147
+
148
+ def fresh_page
149
+ Read.call(client, instance.id)
150
+ end
151
+
152
+ def resolve_stages!
153
+ live = Read.stages(fresh_page)
154
+ resolve_new_children!(instance.stages, live, cross_check: :name, live_key: 'name', label: 'stage')
155
+ end
156
+
157
+ def resolve_sections!
158
+ live_stages = Read.stages(fresh_page)
159
+ instance.stages.each_with_index do |stage, i|
160
+ live_sections = Array(live_stages.dig(i, 'sections'))
161
+ resolve_new_children!(stage.sections, live_sections, cross_check: :heading, live_key: 'heading', label: "stage[#{i}] section")
162
+ end
163
+ end
164
+
165
+ def resolve_fields!
166
+ live_stages = Read.stages(fresh_page)
167
+ instance.stages.each_with_index do |stage, si|
168
+ stage.sections.each_with_index do |section, sci|
169
+ live_section = live_stages.dig(si, 'sections', sci)
170
+ resolve_section_fields!(section, live_section)
171
+ end
172
+ end
173
+ end
174
+
175
+ def resolve_section_fields!(section, live_section)
176
+ if section.kind == :split
177
+ resolve_new_children!(section.left_fields, Array(live_section&.dig('leftDataFields')),
178
+ cross_check: :label, live_key: 'label', label: 'split left field')
179
+ resolve_new_children!(section.right_fields, Array(live_section&.dig('rightDataFields')),
180
+ cross_check: :label, live_key: 'label', label: 'split right field')
181
+ else
182
+ resolve_new_children!(section.left_fields, Array(live_section&.dig('dataFields')),
183
+ cross_check: :label, live_key: 'label', label: 'content field')
184
+ end
185
+ end
186
+
187
+ def resolve_forces!
188
+ live_stages = Read.stages(fresh_page)
189
+ instance.stages.each_with_index do |stage, i|
190
+ live_forces = Array(live_stages.dig(i, 'forces'))
191
+ resolve_new_children!(stage.forces, live_forces, cross_check: :name, live_key: 'name', label: "stage[#{i}] force")
192
+ end
193
+ end
194
+
195
+ # Generic positional-plus-cross-check resolver: `nodes` is the CURRENT in-memory
196
+ # sibling list (loaded ones keep their relative order; new ones are appended, so
197
+ # `nodes.select(&:new?)` is exactly "the ones the last-sent batch just created, in
198
+ # creation order"). `live_docs` is the FRESH re-read of the SAME sibling collection.
199
+ #
200
+ # count guard — live count minus the pre-existing count must equal exactly the
201
+ # number of new nodes this batch created (never "roughly matches").
202
+ # cross-check — the new server docs are assumed appended at the tail, matched
203
+ # 1:1 in creation order; when the node's own `cross_check`
204
+ # attribute is non-nil, it must equal the live doc's `live_key`
205
+ # value, or resolution raises rather than silently mis-mapping an
206
+ # id to the wrong node.
207
+ def resolve_new_children!(nodes, live_docs, cross_check:, live_key:, label:)
208
+ new_nodes = nodes.select(&:new?)
209
+ return if new_nodes.empty?
210
+
211
+ before_count = nodes.size - new_nodes.size
212
+ delta = live_docs.size - before_count
213
+ if delta != new_nodes.size
214
+ raise "StagedExecutor: #{label} count guard failed -- expected #{new_nodes.size} new, " \
215
+ "live delta is #{delta} (before=#{before_count}, live=#{live_docs.size})"
216
+ end
217
+
218
+ matched = live_docs.last(delta)
219
+ new_nodes.each_with_index do |node, idx|
220
+ live = matched[idx]
221
+ expected = node.public_send(cross_check)
222
+ actual = live[live_key]
223
+ if !expected.nil? && expected != actual
224
+ raise "StagedExecutor: #{label} cross-check failed at position #{idx} -- " \
225
+ "expected #{cross_check}=#{expected.inspect}, live #{live_key}=#{actual.inspect}"
226
+ end
227
+
228
+ node.resolve!(live['id'])
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -5,12 +5,45 @@ module Ecoportal
5
5
  # Concrete counterparts of `Base::Template::*` (see there for the page-vs-Template
6
6
  # type asymmetry). `Information` and `RelatedPages` need no extra wiring — both are
7
7
  # flat, read-only models; `ChangeLogEntry` adds the `owner` embed.
8
+ #
9
+ # This module ALSO carries the mutable, editable template model
10
+ # (the repo's internal docs) — "the three
11
+ # lines":
12
+ #
13
+ # template = Ecoportal::API::GraphQL::Model::Template.load(api, id: page_id)
14
+ # template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
15
+ # template.save!(api)
16
+ #
17
+ # `Instance` is the class `.load` returns; `.load` is the ergonomic module-level entry
18
+ # point so callers never have to spell `Instance` themselves.
8
19
  module Template
9
20
  class Information < Base::Template::Information; end
10
21
 
11
22
  class RelatedPages < Base::Template::RelatedPages; end
12
23
 
13
24
  require_relative 'template/change_log_entry'
25
+
26
+ require_relative 'template/node'
27
+ require_relative 'template/option'
28
+ require_relative 'template/binding'
29
+ require_relative 'template/helper'
30
+ require_relative 'template/field'
31
+ require_relative 'template/section'
32
+ require_relative 'template/force'
33
+ require_relative 'template/stage'
34
+ require_relative 'template/read'
35
+ require_relative 'template/command_synthesis'
36
+ require_relative 'template/command_normalizer'
37
+ require_relative 'template/command_grouper'
38
+ require_relative 'template/staged_executor'
39
+ require_relative 'template/instance'
40
+
41
+ # @param client [Ecoportal::API::GraphQL] the top-level gem client.
42
+ # @param id [String] the template's page id.
43
+ # @return [Instance]
44
+ def self.load(client, id:)
45
+ Instance.load(client, id: id)
46
+ end
14
47
  end
15
48
  end
16
49
  end
@@ -14,6 +14,7 @@ require_relative 'model/user'
14
14
  require_relative 'model/person_member'
15
15
  require_relative 'model/file_attachment'
16
16
  require_relative 'model/file_container'
17
+ require_relative 'model/temp_image'
17
18
  require_relative 'model/field'
18
19
  require_relative 'model/page'
19
20
  require_relative 'model/page_union'
@@ -31,16 +31,23 @@ module Ecoportal
31
31
  # @param other_tags [Array<String>, nil] tags — the only supported way to tag a
32
32
  # container (there is no separate tag mutation; confirmed 2026-07-30).
33
33
  # @param container_id [String, nil] add a new VERSION to an existing container.
34
- def query(filename:, filetype:, filesize:, url:, location_ids: nil, other_tags: nil, container_id: nil, **kargs, &block)
34
+ # @param client_mutation_id [String, nil] optional correlation/tracing id --
35
+ # `UploadInput`'s own `clientMutationId` (confirmed in this file's own header,
36
+ # `UploadInput { clientMutationId filename filetype filesize locationIds
37
+ # otherTags url containerId }`). Omitted entirely when not given, so every
38
+ # existing caller's input shape is byte-identical to before.
39
+ def query(filename:, filetype:, filesize:, url:, location_ids: nil, other_tags: nil,
40
+ container_id: nil, client_mutation_id: nil, **kargs, &block)
35
41
  input = {
36
42
  filename: filename,
37
43
  filetype: filetype,
38
44
  filesize: filesize.to_s,
39
45
  url: url
40
46
  }
41
- input[:locationIds] = Array(location_ids) if location_ids
42
- input[:otherTags] = Array(other_tags) if other_tags
43
- input[:containerId] = container_id if container_id
47
+ input[:locationIds] = Array(location_ids) if location_ids
48
+ input[:otherTags] = Array(other_tags) if other_tags
49
+ input[:containerId] = container_id if container_id
50
+ input[:clientMutationId] = client_mutation_id unless client_mutation_id.nil?
44
51
 
45
52
  super(input: input, **kargs, &block)
46
53
  end
@@ -0,0 +1,88 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Mutation
5
+ module Image
6
+ # Registers an object already uploaded to S3 as a `TempImage` -- the upload-staging
7
+ # model behind Image Gallery (and other, non-gallery) image uploads.
8
+ #
9
+ # ★ Sibling to `Mutation::FileContainer::Upload`, NOT a variant of it: an Image
10
+ # Gallery image is explicitly NOT a `FileContainer`. Verified against the live
11
+ # ecoPortal server source (read-only, /tmp/work/ecoPortal):
12
+ # * `app/graphql/mutations/images/upload.rb:1-23` -- the mutation builds an
13
+ # `Enzyme::ImageReference::TempImage`, not `uploadFile`/`FileContainer`.
14
+ # * `app/graphql/types/mutation_type.rb:224` -- schema field `uploadImage`
15
+ # (`field :upload_image, mutation: Mutations::Images::Upload`).
16
+ # * `app/graphql/types/enums/files/image_upload_type_enum.rb` -- `type:` is a
17
+ # live enum (`image_gallery`, `policy_group`, `theme`, `user`, `organization`,
18
+ # `brand`); `"image_gallery"` is the one `Base::Page::DataField::ImageGallery`
19
+ # consumes.
20
+ #
21
+ # The SIGNATURE and S3-POST steps upstream of this call are IDENTICAL to the
22
+ # `uploadFile` flow (same `fileUploadSignature`, same multipart POST) -- see
23
+ # `FileUpload::Client#upload_image`, which shares its presign+store step with
24
+ # `#upload` and only diverges here, at registration.
25
+ #
26
+ # Verified against the live schema:
27
+ # uploadImage(input: ImagesUploadInput!) -> ImagesUploadPayload
28
+ # { clientMutationId errors item: TempImage }
29
+ # ImagesUploadInput { clientMutationId key type id import }
30
+ class Upload < Logic::Mutation
31
+ field_name :uploadImage
32
+
33
+ class_resolver :payload_class, 'Ecoportal::API::GraphQL::Payload::ImagesUpload'
34
+
35
+ # @param key [String] the S3 object key produced by the S3 POST step.
36
+ # @param type [String, Symbol] live enum value -- `FileUpload::Client` always
37
+ # passes `'image_gallery'` today; any other value is sent verbatim for a future
38
+ # caller.
39
+ # @param id [String, nil] a target id the image is being uploaded FOR, in
40
+ # contexts that need one (e.g. an existing brand/policy_group/user). Unused by
41
+ # the Image Gallery context -- that attach happens later, via `updatePage`, not
42
+ # here.
43
+ # @param import [Boolean] matches the live schema's own default (`true`).
44
+ # @param client_mutation_id [String, nil] optional correlation/tracing id --
45
+ # `ImagesUploadInput`'s own `clientMutationId` (confirmed in this file's own
46
+ # header, `ImagesUploadInput { clientMutationId key type id import }`). Omitted
47
+ # entirely when not given, so every existing caller's input shape is
48
+ # byte-identical to before.
49
+ def query(key:, type:, id: nil, import: true, client_mutation_id: nil, **kargs, &block)
50
+ super(input: build_input(key: key, type: type, id: id, import: import,
51
+ client_mutation_id: client_mutation_id), **kargs, &block)
52
+ end
53
+
54
+ private
55
+
56
+ def build_input(key:, type:, id:, import:, client_mutation_id: nil)
57
+ input = {key: key, type: type.to_s, import: import}
58
+ input[:id] = id if id
59
+ input[:clientMutationId] = client_mutation_id unless client_mutation_id.nil?
60
+ input
61
+ end
62
+
63
+ def basic_block(&block)
64
+ payload_block = block || default_payload_block
65
+ proc {
66
+ mutation(input: :ImagesUploadInput!) {
67
+ uploadImage(input: :input, &payload_block)
68
+ }
69
+ }
70
+ end
71
+
72
+ def default_payload_block
73
+ proc {
74
+ errors { details fullMessages }
75
+ item {
76
+ id
77
+ s3Key
78
+ complete
79
+ error
80
+ }
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Mutation
5
+ # Namespaced `Image` (matches the schema's own `TempImageType`/`ImagesUploadInput`
6
+ # naming) -- sibling to `Mutation::FileContainer`, NOT a variant of it.
7
+ module Image
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ require_relative 'image/upload'
@@ -3,15 +3,47 @@ module Ecoportal
3
3
  class GraphQL
4
4
  module Mutation
5
5
  module Template
6
+ # `createPageTemplate(input: CreatePageTemplateInput!)` — creates a template page from
7
+ # a `WorkflowCommand` batch.
8
+ #
9
+ # ★ `registerId` IS NOT COSMETIC — it is what makes the create AUTHORIZABLE.
10
+ #
11
+ # The backend resolves a page's registers BY TAG
12
+ # (`NewEp::Pages::AssociatedRegisters`: org registers whose `filter_tags` are a subset
13
+ # of the page's `combined_tags`), and `RegisterTemplatePermissionChecker` returns
14
+ # `false` outright when that set is EMPTY. A template created without `registerId`
15
+ # carries no `base_tags`, so it is associated with ZERO registers, so the check fails
16
+ # regardless of what the account is granted — surfacing as the misleading
17
+ # `You are not authorized to perform: edit_template_basic_settings`.
18
+ # `Pages::Templates::CreateForm#bind_register!` is what closes that hole: given
19
+ # `register_id` it sets `page.base_tags = register.filter_tags` BEFORE the commands run.
20
+ #
21
+ # SHAPE (verified against the LIVE eu.live SDL, 2026-09-04): `registerId: ID` is an
22
+ # INPUT FIELD of `CreatePageTemplateInput`, not a sibling argument of the mutation —
23
+ # the backend mutation declares `argument :register_id, ID, required: false` next to
24
+ # `argument :commands`, but `Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation`
25
+ # folds every argument into the generated input object. So it travels inside `input:`,
26
+ # exactly like `commands`.
6
27
  class Create < Logic::Mutation
7
- SCHEMA_VERSION = '20260605'.freeze
28
+ SCHEMA_VERSION = '20260904'.freeze
8
29
 
9
30
  field_name :createPageTemplate
10
31
 
11
32
  class_resolver :payload_class, 'Ecoportal::API::GraphQL::Payload::Template::Create'
12
33
 
13
- def query(commands:, **kargs, &block)
14
- super(input: { commands: commands }, **kargs, &block)
34
+ # @param commands [Array<Hash>] the `WorkflowCommand` batch.
35
+ # @param register_id [String, nil] the register to bind the new template to. Omitted
36
+ # when nil — never sent as an explicit null, so existing call sites keep producing
37
+ # the byte-identical input they produced before this key existed.
38
+ def query(commands:, register_id: nil, **kargs, &block)
39
+ super(input: self.class.build_input(commands: commands, register_id: register_id), **kargs, &block)
40
+ end
41
+
42
+ # Extracted so the omit-nil rule is testable without a client or a network stub.
43
+ def self.build_input(commands:, register_id: nil)
44
+ {commands: commands}.tap do |input|
45
+ input[:registerId] = register_id unless register_id.nil?
46
+ end
15
47
  end
16
48
 
17
49
  private
@@ -10,9 +10,11 @@ module Ecoportal
10
10
 
11
11
  class_resolver :payload_class, 'Ecoportal::API::GraphQL::Payload::Template::Update'
12
12
 
13
- def query(id:, patch_ver:, commands:, **kargs, &block)
13
+ def query(id:, patch_ver:, commands:, client_mutation_id: nil, **kargs, &block)
14
+ input = { id: id, patchVer: patch_ver, commands: commands }
15
+ input[:clientMutationId] = client_mutation_id unless client_mutation_id.nil?
14
16
  super(
15
- input: { id: id, patchVer: patch_ver, commands: commands },
17
+ input: input,
16
18
  **kargs,
17
19
  &block
18
20
  )
@@ -18,3 +18,4 @@ require_relative 'mutation/ai_summary'
18
18
  require_relative 'mutation/kickstand'
19
19
  require_relative 'mutation/template'
20
20
  require_relative 'mutation/file_container'
21
+ require_relative 'mutation/image'
@@ -0,0 +1,14 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Payload
5
+ # `uploadImage` -> `ImagesUploadPayload { clientMutationId errors item: TempImage }`.
6
+ # Sibling to `Payload::Upload` (`uploadFile` -> `FileContainer`) -- NOT a variant of
7
+ # it. See `Base::TempImage`'s header for why the item type differs.
8
+ class ImagesUpload < Logic::Payload
9
+ class_resolver :item_class, 'Ecoportal::API::GraphQL::Model::TempImage'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -22,3 +22,4 @@ require_relative 'payload/execute_workflow_commands'
22
22
  require_relative 'payload/kickstand'
23
23
  require_relative 'payload/template'
24
24
  require_relative 'payload/upload'
25
+ require_relative 'payload/images_upload'
@@ -4,7 +4,7 @@ module Ecoportal
4
4
  module Query
5
5
  # DB-backed full-data org search — returns PageUnion nodes with patchVer and field IDs.
6
6
  # Required pre-update fetch: always use this (not RegisterPreviewPages) before updatePage.
7
- # See .ai-assistance/code/search_filters.md — "The Required Pattern: Find Before Mutate".
7
+ # See the repo's internal docs — "The Required Pattern: Find Before Mutate".
8
8
  class Pages < Logic::QueryConnection
9
9
  field_name :pages
10
10
  # Response nests under currentOrganization.pages; needed when instantiated
@@ -3,7 +3,7 @@ module Ecoportal
3
3
  class GraphQL
4
4
  module Query
5
5
  # Fetches the org-level permission set: currentOrganization.permissions.
6
- # Returns a Model::Permissions whose ~83 canXXX fields are AuthorizationResult.
6
+ # Returns a Model::Permissions whose ~81 canXXX fields are AuthorizationResult.
7
7
  # Selects the :Permissions fragment (which spreads :AuthorizationResult per field).
8
8
  class Permissions < Logic::Query
9
9
  field_name :permissions
@@ -1,5 +1,5 @@
1
1
  module Ecoportal
2
2
  module API
3
- GRAPQL_VERSION = '2.2.0'.freeze
3
+ GRAPQL_VERSION = '3.0.0'.freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ecoportal-api-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oscar Segura
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json-canonicalization
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: pry
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -199,7 +213,7 @@ extra_rdoc_files: []
199
213
  files:
200
214
  - CHANGELOG.md
201
215
  - LICENSE
202
- - README.md
216
+ - README.gem.md
203
217
  - lib/ecoportal/api-graphql.rb
204
218
  - lib/ecoportal/api/common.graphql.rb
205
219
  - lib/ecoportal/api/common/graphql.rb
@@ -311,6 +325,7 @@ files:
311
325
  - lib/ecoportal/api/graphql/base/preview_page.rb
312
326
  - lib/ecoportal/api/graphql/base/register.rb
313
327
  - lib/ecoportal/api/graphql/base/resource.rb
328
+ - lib/ecoportal/api/graphql/base/temp_image.rb
314
329
  - lib/ecoportal/api/graphql/base/template.rb
315
330
  - lib/ecoportal/api/graphql/base/template/change_log_entry.rb
316
331
  - lib/ecoportal/api/graphql/builder.rb
@@ -534,6 +549,7 @@ files:
534
549
  - lib/ecoportal/api/graphql/input/workflow_command/field_config/table.rb
535
550
  - lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb
536
551
  - lib/ecoportal/api/graphql/input/workflow_command/move_field.rb
552
+ - lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb
537
553
  - lib/ecoportal/api/graphql/input/workflow_command/move_stage.rb
538
554
  - lib/ecoportal/api/graphql/input/workflow_command/move_viewable_field.rb
539
555
  - lib/ecoportal/api/graphql/input/workflow_command/remove_action_tag.rb
@@ -624,8 +640,23 @@ files:
624
640
  - lib/ecoportal/api/graphql/model/preview_page.rb
625
641
  - lib/ecoportal/api/graphql/model/register.rb
626
642
  - lib/ecoportal/api/graphql/model/resource.rb
643
+ - lib/ecoportal/api/graphql/model/temp_image.rb
627
644
  - lib/ecoportal/api/graphql/model/template.rb
645
+ - lib/ecoportal/api/graphql/model/template/binding.rb
628
646
  - lib/ecoportal/api/graphql/model/template/change_log_entry.rb
647
+ - lib/ecoportal/api/graphql/model/template/command_grouper.rb
648
+ - lib/ecoportal/api/graphql/model/template/command_normalizer.rb
649
+ - lib/ecoportal/api/graphql/model/template/command_synthesis.rb
650
+ - lib/ecoportal/api/graphql/model/template/field.rb
651
+ - lib/ecoportal/api/graphql/model/template/force.rb
652
+ - lib/ecoportal/api/graphql/model/template/helper.rb
653
+ - lib/ecoportal/api/graphql/model/template/instance.rb
654
+ - lib/ecoportal/api/graphql/model/template/node.rb
655
+ - lib/ecoportal/api/graphql/model/template/option.rb
656
+ - lib/ecoportal/api/graphql/model/template/read.rb
657
+ - lib/ecoportal/api/graphql/model/template/section.rb
658
+ - lib/ecoportal/api/graphql/model/template/stage.rb
659
+ - lib/ecoportal/api/graphql/model/template/staged_executor.rb
629
660
  - lib/ecoportal/api/graphql/model/user.rb
630
661
  - lib/ecoportal/api/graphql/mutation.rb
631
662
  - lib/ecoportal/api/graphql/mutation/action.rb
@@ -642,6 +673,8 @@ files:
642
673
  - lib/ecoportal/api/graphql/mutation/contractor_entity/update_manager_settings.rb
643
674
  - lib/ecoportal/api/graphql/mutation/file_container.rb
644
675
  - lib/ecoportal/api/graphql/mutation/file_container/upload.rb
676
+ - lib/ecoportal/api/graphql/mutation/image.rb
677
+ - lib/ecoportal/api/graphql/mutation/image/upload.rb
645
678
  - lib/ecoportal/api/graphql/mutation/kickstand.rb
646
679
  - lib/ecoportal/api/graphql/mutation/kickstand/bulk_update_jobs.rb
647
680
  - lib/ecoportal/api/graphql/mutation/kickstand/bulk_update_workflows.rb
@@ -706,6 +739,7 @@ files:
706
739
  - lib/ecoportal/api/graphql/payload/contractor_entity/update.rb
707
740
  - lib/ecoportal/api/graphql/payload/execute_workflow_commands.rb
708
741
  - lib/ecoportal/api/graphql/payload/force_commands.rb
742
+ - lib/ecoportal/api/graphql/payload/images_upload.rb
709
743
  - lib/ecoportal/api/graphql/payload/kickstand.rb
710
744
  - lib/ecoportal/api/graphql/payload/kickstand/bulk_update_jobs.rb
711
745
  - lib/ecoportal/api/graphql/payload/kickstand/bulk_update_workflows.rb