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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +448 -31
- data/README.gem.md +53 -0
- data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
- data/lib/ecoportal/api/common/graphql/client.rb +38 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
- data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
- data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
- data/lib/ecoportal/api/graphql/base.rb +1 -0
- data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +8 -3
- data/lib/ecoportal/api/graphql/fragment/permissions.rb +0 -2
- data/lib/ecoportal/api/graphql/input/page/update.rb +109 -6
- data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +2 -0
- data/lib/ecoportal/api/graphql/logic/base_query.rb +1 -1
- data/lib/ecoportal/api/graphql/model/permissions.rb +6 -0
- data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
- data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
- data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
- data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
- data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
- data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
- data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
- data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
- data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
- data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
- data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
- data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
- data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
- data/lib/ecoportal/api/graphql/model/template.rb +33 -0
- data/lib/ecoportal/api/graphql/model.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
- data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
- data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
- data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
- data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
- data/lib/ecoportal/api/graphql/mutation.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql/query/pages.rb +1 -1
- data/lib/ecoportal/api/graphql/query/permissions.rb +1 -1
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +36 -2
- 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
|
-
|
|
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]
|
|
42
|
-
input[:otherTags]
|
|
43
|
-
input[:containerId]
|
|
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 = '
|
|
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
|
-
|
|
14
|
-
|
|
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:
|
|
17
|
+
input: input,
|
|
16
18
|
**kargs,
|
|
17
19
|
&block
|
|
18
20
|
)
|
|
@@ -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
|
|
@@ -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
|
|
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 ~
|
|
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
|
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:
|
|
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
|