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
|
@@ -26,21 +26,38 @@ module Ecoportal
|
|
|
26
26
|
# `X-ECOPORTAL-API-KEY` is needed here: every ecoPortal call is GraphQL, on the session
|
|
27
27
|
# token this client already holds.
|
|
28
28
|
#
|
|
29
|
-
#
|
|
29
|
+
# ★ Extended 2026-09 with `#upload_image`/`#upload_all_images` — steps 1-2 above (the
|
|
30
|
+
# presign + S3 POST) are IDENTICAL for an Image Gallery image; only step 3 differs
|
|
31
|
+
# (`uploadImage` -> a `TempImage`, not `uploadFile` -> a `FileContainer`). Confirmed
|
|
32
|
+
# against the live ecoPortal server source (read-only, /tmp/work/ecoPortal) that an
|
|
33
|
+
# Image Gallery image is genuinely NOT a `FileContainer` — see
|
|
34
|
+
# `Mutation::Image::Upload`'s and `Base::Page::DataField::ImageGallery`'s own headers
|
|
35
|
+
# for the full evidence trail. `#presign_and_store!` is the shared internal path both
|
|
36
|
+
# `#upload_one` and `#upload_one_image` call; `#run_batch` is the shared batch-with-
|
|
37
|
+
# progress-callback loop both `#upload_all` and `#upload_all_images` call.
|
|
38
|
+
#
|
|
39
|
+
# Single file, FileContainer (File-type fields):
|
|
30
40
|
# id = api.file_upload.upload('/path/report.pdf')
|
|
31
41
|
# page.components.get_by_name('Report').file_container_ids = [id]
|
|
32
42
|
#
|
|
33
|
-
#
|
|
43
|
+
# Single file, TempImage (Image Gallery fields):
|
|
44
|
+
# source_id = api.file_upload.upload_image('/path/photo.jpg')
|
|
45
|
+
# page.components.get_by_name('Site Photos').add_source_images([{source_id: source_id, file_name: 'photo.jpg'}])
|
|
46
|
+
#
|
|
47
|
+
# Many files, concurrently, with per-file error isolation (nothing raises out) — same
|
|
48
|
+
# shape for both flows:
|
|
34
49
|
# results = api.file_upload.upload_all(paths, threads: 4) do |r|
|
|
35
50
|
# puts r.success? ? "#{r.file} -> #{r.container_id}" : "#{r.file} FAILED: #{r.error}"
|
|
36
51
|
# end
|
|
37
52
|
# results.select(&:error?)
|
|
38
53
|
#
|
|
39
|
-
# Instrumentation / middleware — hooks fire per stage, per file
|
|
54
|
+
# Instrumentation / middleware — hooks fire per stage, per file, for EITHER flow (the
|
|
55
|
+
# stage names are shared: `:register` fires once whether the registration mutation was
|
|
56
|
+
# `uploadFile` or `uploadImage`):
|
|
40
57
|
# client = api.file_upload
|
|
41
58
|
# client.on(:signature) { |creds| logger.info "presigned #{creds.endpoint}" }
|
|
42
59
|
# client.on(:storage) { |key, response| logger.info "S3 #{response.code} #{key}" }
|
|
43
|
-
# client.on(:register) { |payload| logger.info "
|
|
60
|
+
# client.on(:register) { |payload| logger.info "registered #{payload.item&.id}" }
|
|
44
61
|
class Client
|
|
45
62
|
include Ecoportal::API::GraphQL::Concerns::Threadable
|
|
46
63
|
|
|
@@ -51,9 +68,10 @@ module Ecoportal
|
|
|
51
68
|
|
|
52
69
|
STAGES = %i[signature storage register].freeze
|
|
53
70
|
# Fallback only — the real value is a condition inside the returned policy.
|
|
54
|
-
DEFAULT_ENCRYPTION
|
|
55
|
-
DEFAULT_MIME
|
|
56
|
-
|
|
71
|
+
DEFAULT_ENCRYPTION = 'AES256'.freeze
|
|
72
|
+
DEFAULT_MIME = 'application/octet-stream'.freeze
|
|
73
|
+
DEFAULT_IMAGE_TYPE = 'image_gallery'.freeze
|
|
74
|
+
MAX_THREADS = 8
|
|
57
75
|
|
|
58
76
|
# One entry per input file. Mirrors `ecoportal-api-v2`'s
|
|
59
77
|
# `S3::Files::BatchUpload::FileResult` so batch code reads the same in both stacks.
|
|
@@ -77,13 +95,36 @@ module Ecoportal
|
|
|
77
95
|
end
|
|
78
96
|
end
|
|
79
97
|
|
|
98
|
+
# Sibling to {Result} for the image-upload pipeline — same shape, different item
|
|
99
|
+
# (a `TempImage`, not a `FileContainer`).
|
|
100
|
+
ImageResult = Struct.new(:file) do
|
|
101
|
+
attr_accessor :key, :payload, :error
|
|
102
|
+
|
|
103
|
+
def error?
|
|
104
|
+
!error.nil?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def success?
|
|
108
|
+
!error? && !temp_image_id.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def temp_image
|
|
112
|
+
payload&.item
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def temp_image_id
|
|
116
|
+
temp_image&.id
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
80
120
|
def initialize(graphql_client)
|
|
81
121
|
@graphql = graphql_client
|
|
82
122
|
@hooks = {}
|
|
83
123
|
end
|
|
84
124
|
|
|
85
125
|
# Register a stage hook. Called for every file, in that file's own thread — keep it
|
|
86
|
-
# thread-safe (or wrap it in your own mutex).
|
|
126
|
+
# thread-safe (or wrap it in your own mutex). Fires identically for {#upload} and
|
|
127
|
+
# {#upload_image} — the STAGE names are shared between both flows.
|
|
87
128
|
# @param stage [:signature, :storage, :register]
|
|
88
129
|
def on(stage, &block)
|
|
89
130
|
stage = stage.to_sym
|
|
@@ -102,33 +143,32 @@ module Ecoportal
|
|
|
102
143
|
result.container_id
|
|
103
144
|
end
|
|
104
145
|
|
|
146
|
+
# @return [String] the new `TempImage` id (the `sourceId` an Image Gallery
|
|
147
|
+
# `ImageInput` write needs — see `Base::Page::DataField::ImageGallery#
|
|
148
|
+
# add_source_images`).
|
|
149
|
+
# @raise [Error] on any failure — use {#upload_all_images} for non-raising per-file
|
|
150
|
+
# isolation.
|
|
151
|
+
def upload_image(file_path, **kargs)
|
|
152
|
+
result = upload_one_image(file_path, **kargs)
|
|
153
|
+
raise result.error if result.error?
|
|
154
|
+
|
|
155
|
+
result.temp_image_id
|
|
156
|
+
end
|
|
157
|
+
|
|
105
158
|
# Uploads many files with bounded concurrency. Never raises for a single file: each
|
|
106
159
|
# {Result} carries its own error, and the block is called as each finishes.
|
|
107
160
|
#
|
|
108
161
|
# @param threads [Integer] max concurrent uploads (`1` = inline, deterministic).
|
|
109
162
|
# @return [Array<Result>] one per input file; order is not guaranteed when threaded.
|
|
110
163
|
def upload_all(file_paths, threads: 4, **kargs, &block)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
results = []
|
|
114
|
-
spawned = []
|
|
115
|
-
|
|
116
|
-
# Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
|
|
117
|
-
# here so N threads don't race for the first one.
|
|
118
|
-
credentials
|
|
119
|
-
|
|
120
|
-
with_preserved_thread_globals do
|
|
121
|
-
files.each do |file|
|
|
122
|
-
new_thread(spawned, max: max) do
|
|
123
|
-
result = upload_one(file, **kargs)
|
|
124
|
-
mutex(:results).synchronize { results << result }
|
|
125
|
-
block&.call(result)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
164
|
+
run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one(file, **kargs) }
|
|
165
|
+
end
|
|
129
166
|
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
# Sibling to {#upload_all} for the image-upload pipeline — identical concurrency,
|
|
168
|
+
# isolation and progress-callback semantics.
|
|
169
|
+
# @return [Array<ImageResult>]
|
|
170
|
+
def upload_all_images(file_paths, threads: 4, **kargs, &block)
|
|
171
|
+
run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one_image(file, **kargs) }
|
|
132
172
|
end
|
|
133
173
|
|
|
134
174
|
# Force the next upload to presign again (e.g. after a policy expiry).
|
|
@@ -141,13 +181,10 @@ module Ecoportal
|
|
|
141
181
|
|
|
142
182
|
def upload_one(file_path, location_ids: nil, tags: nil, container_id: nil)
|
|
143
183
|
Result.new(file_path).tap do |result|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
creds = credentials
|
|
147
|
-
result.key = storage_key(creds, file_path)
|
|
148
|
-
store!(creds, result.key, file_path)
|
|
184
|
+
_creds, key = presign_and_store!(file_path)
|
|
185
|
+
result.key = key
|
|
149
186
|
result.payload = register(
|
|
150
|
-
file_path,
|
|
187
|
+
file_path, key,
|
|
151
188
|
location_ids: location_ids, tags: tags, container_id: container_id
|
|
152
189
|
)
|
|
153
190
|
rescue StandardError => e
|
|
@@ -155,6 +192,61 @@ module Ecoportal
|
|
|
155
192
|
end
|
|
156
193
|
end
|
|
157
194
|
|
|
195
|
+
def upload_one_image(file_path, type: DEFAULT_IMAGE_TYPE, id: nil, import: true)
|
|
196
|
+
ImageResult.new(file_path).tap do |result|
|
|
197
|
+
_creds, key = presign_and_store!(file_path)
|
|
198
|
+
result.key = key
|
|
199
|
+
result.payload = register_image(file_path, key, type: type, id: id, import: import)
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
result.error = e
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Shared by BOTH flows: presign (memoised per batch), build a collision-proof S3
|
|
206
|
+
# key, and the multipart POST itself. Only the THIRD step (the registration
|
|
207
|
+
# mutation — `uploadFile` vs `uploadImage`) differs between {#upload_one} and
|
|
208
|
+
# {#upload_one_image}.
|
|
209
|
+
# @return [Array(Query::FileUploadSignature::SignatureResponse, String)] the
|
|
210
|
+
# credentials used and the S3 key the file was stored under.
|
|
211
|
+
def presign_and_store!(file_path)
|
|
212
|
+
raise MissingLocalFile, "no such file: #{file_path}" unless ::File.file?(file_path)
|
|
213
|
+
|
|
214
|
+
creds = credentials
|
|
215
|
+
key = storage_key(creds, file_path)
|
|
216
|
+
store!(creds, key, file_path)
|
|
217
|
+
[creds, key]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Shared by {#upload_all}/{#upload_all_images}: bounded-concurrency loop, presign-
|
|
221
|
+
# once-per-batch, per-file error isolation, and a progress callback fired as each
|
|
222
|
+
# result lands (not deferred to the end). `upload_one` (the block) is the only thing
|
|
223
|
+
# that differs between the two callers.
|
|
224
|
+
# @yieldparam file_path [String]
|
|
225
|
+
# @yieldreturn [Result, ImageResult]
|
|
226
|
+
def run_batch(file_paths, threads:, on_result: nil)
|
|
227
|
+
files = Array(file_paths).flatten.compact
|
|
228
|
+
max = threads.to_i.clamp(1, MAX_THREADS)
|
|
229
|
+
results = []
|
|
230
|
+
spawned = []
|
|
231
|
+
|
|
232
|
+
# Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
|
|
233
|
+
# here so N threads don't race for the first one.
|
|
234
|
+
credentials
|
|
235
|
+
|
|
236
|
+
with_preserved_thread_globals do
|
|
237
|
+
files.each do |file|
|
|
238
|
+
new_thread(spawned, max: max) do
|
|
239
|
+
result = yield(file)
|
|
240
|
+
mutex(:results).synchronize { results << result }
|
|
241
|
+
on_result&.call(result)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
spawned.each(&:join)
|
|
247
|
+
results
|
|
248
|
+
end
|
|
249
|
+
|
|
158
250
|
# Memoised behind a mutex so a batch presigns once, not once per thread.
|
|
159
251
|
def credentials
|
|
160
252
|
mutex(:credentials).synchronize do
|
|
@@ -167,7 +259,8 @@ module Ecoportal
|
|
|
167
259
|
# `uploads/<userId>/<epoch-ms>-<token>-/<basename>` — the shape the web client uses.
|
|
168
260
|
# That stamp+token segment is what stops same-named files colliding: the previous
|
|
169
261
|
# implementation concatenated `upload_prefix + filename`, so two uploads of
|
|
170
|
-
# `report.pdf` overwrote each other in the bucket.
|
|
262
|
+
# `report.pdf` overwrote each other in the bucket. Shared by BOTH flows — an image
|
|
263
|
+
# upload needs the identical collision-proofing.
|
|
171
264
|
def storage_key(creds, file_path)
|
|
172
265
|
stamp = (Time.now.to_f * 1000).to_i
|
|
173
266
|
"#{creds.upload_prefix}#{stamp}-#{SecureRandom.alphanumeric(20)}-/#{::File.basename(file_path)}"
|
|
@@ -224,6 +317,18 @@ module Ecoportal
|
|
|
224
317
|
payload
|
|
225
318
|
end
|
|
226
319
|
|
|
320
|
+
def register_image(file_path, key, type:, id:, import:)
|
|
321
|
+
payload = Mutation::Image::Upload.new(@graphql.client).query(
|
|
322
|
+
key: key, type: type, id: id, import: import
|
|
323
|
+
)
|
|
324
|
+
fire(:register, payload)
|
|
325
|
+
|
|
326
|
+
raise RegistrationFailed, "uploadImage failed for #{::File.basename(file_path)}: #{payload.error_doc}" if payload.error?
|
|
327
|
+
raise RegistrationFailed, "uploadImage returned no item for #{::File.basename(file_path)}" unless payload.item&.id
|
|
328
|
+
|
|
329
|
+
payload
|
|
330
|
+
end
|
|
331
|
+
|
|
227
332
|
# The bucket policy dictates the encryption header, so echo it instead of assuming:
|
|
228
333
|
# the policy is base64 JSON whose `conditions` carry `x-amz-server-side-encryption`.
|
|
229
334
|
def encryption(creds)
|
|
@@ -99,7 +99,7 @@ module Ecoportal
|
|
|
99
99
|
# `templateContainerUid` is the SERIES key: many versions may carry the same uid,
|
|
100
100
|
# but only one may be published (`active: true`) per organization at a time —
|
|
101
101
|
# publishing another with the same uid unpublishes the incumbent. See
|
|
102
|
-
#
|
|
102
|
+
# the repo's internal docs.
|
|
103
103
|
template @skip(if: $only_content) {
|
|
104
104
|
templateContainerUid
|
|
105
105
|
active
|
|
@@ -137,7 +137,7 @@ module Ecoportal
|
|
|
137
137
|
# per-type members carry no description, so `updatePage` cannot set it.
|
|
138
138
|
#
|
|
139
139
|
# Integrations use it as a pseudo external-id on fields (we do so on templates;
|
|
140
|
-
#
|
|
140
|
+
# customers do so on page instances — confirmed working on instances with a
|
|
141
141
|
# non-superuser service account, 2026-07-31). Fetched by default so those
|
|
142
142
|
# integrations can read it back.
|
|
143
143
|
description @skip(if: $only_content)
|
|
@@ -243,11 +243,16 @@ module Ecoportal
|
|
|
243
243
|
|
|
244
244
|
fragment imageGalleryField on ImageGallery {
|
|
245
245
|
images @include(if: $content) {
|
|
246
|
+
id
|
|
247
|
+
weight
|
|
246
248
|
downloadUrl
|
|
247
249
|
caption
|
|
248
250
|
fileName
|
|
249
251
|
fileSize
|
|
250
|
-
uploadId
|
|
252
|
+
uploadId @skip(if: $only_content)
|
|
253
|
+
sensitiveContent @skip(if: $only_content)
|
|
254
|
+
inaccurateDescription @skip(if: $only_content)
|
|
255
|
+
inaccurateExtractedText @skip(if: $only_content)
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
|
|
@@ -58,13 +58,11 @@ module Ecoportal
|
|
|
58
58
|
pageCanListDrafts { ...AuthorizationResult }
|
|
59
59
|
pageCanRestore { ...AuthorizationResult }
|
|
60
60
|
pageCanUseDrafts { ...AuthorizationResult }
|
|
61
|
-
pageSmartAssistantCanChat { ...AuthorizationResult }
|
|
62
61
|
personSchemaCanAccessManager { ...AuthorizationResult }
|
|
63
62
|
publicTemplatesCanCopyLink { ...AuthorizationResult }
|
|
64
63
|
publicTemplatesCanIndex { ...AuthorizationResult }
|
|
65
64
|
publicTemplatesCanListEntries { ...AuthorizationResult }
|
|
66
65
|
publicTemplatesCanManage { ...AuthorizationResult }
|
|
67
|
-
registerAssistantCanChat { ...AuthorizationResult }
|
|
68
66
|
registerCanAccessRegisterTools { ...AuthorizationResult }
|
|
69
67
|
registerCanCreate { ...AuthorizationResult }
|
|
70
68
|
registerCanLimitTemplates { ...AuthorizationResult }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
1
3
|
module Ecoportal
|
|
2
4
|
module API
|
|
3
5
|
class GraphQL
|
|
@@ -15,7 +17,19 @@ module Ecoportal
|
|
|
15
17
|
# patchVer is always included when present (required for concurrency control).
|
|
16
18
|
#
|
|
17
19
|
# Options:
|
|
18
|
-
# stage_id: ID — target a specific stage
|
|
20
|
+
# stage_id: ID — target a specific stage. Optional even for a
|
|
21
|
+
# PHASED page: when omitted, it is DERIVED from
|
|
22
|
+
# the owning stage of the data fields being
|
|
23
|
+
# updated in this same call (see #derive_stage_id)
|
|
24
|
+
# — a PHASED `updatePage` has been observed to 500
|
|
25
|
+
# without a `stageId` on an Image Gallery / File
|
|
26
|
+
# field write (live-diagnosed, page
|
|
27
|
+
# a customer's phased page), and sending it
|
|
28
|
+
# whenever derivable is always safe even where it
|
|
29
|
+
# turns out not to be strictly required. Pass this
|
|
30
|
+
# explicitly to override the derivation, or to
|
|
31
|
+
# target a stage submit/close-out when no field on
|
|
32
|
+
# that stage is dirty yet.
|
|
19
33
|
# submit: Boolean — submit the stage (triggers task transitions server-side)
|
|
20
34
|
# publish: Boolean — publish a draft page
|
|
21
35
|
# show_hidden_data: Boolean — include hidden data in the mutation response
|
|
@@ -37,17 +51,31 @@ module Ecoportal
|
|
|
37
51
|
# location_ids: [ID] — set page locations (PageInput.locations); the
|
|
38
52
|
# model's locations embed is read_only, so this is
|
|
39
53
|
# the way to write them
|
|
40
|
-
# client_mutation_id: String
|
|
54
|
+
# client_mutation_id: String — correlation id for tracing this call in logs /
|
|
55
|
+
# the server's own activity trail. Defaults to a
|
|
56
|
+
# fresh `SecureRandom.uuid` PER CALL — every
|
|
57
|
+
# mutation this input builds is now traceable by
|
|
58
|
+
# default, not just the ones a caller remembered
|
|
59
|
+
# to tag. Pass an explicit id to correlate several
|
|
60
|
+
# calls under one value, or `''`/`nil` to omit the
|
|
61
|
+
# key entirely. NOTE: this is for CORRELATION only
|
|
62
|
+
# — the server does NOT dedupe mutations on it, so
|
|
63
|
+
# it buys no idempotency; a retried call with the
|
|
64
|
+
# same clientMutationId still re-applies the write.
|
|
65
|
+
# Any idempotency has to be client-side (e.g. this
|
|
66
|
+
# gem's own dirty-tracking not re-sending a field
|
|
67
|
+
# that already round-tripped).
|
|
41
68
|
def from_model(model, stage_id: nil, submit: nil, publish: nil, show_hidden_data: nil,
|
|
42
69
|
task: nil, complete_page_task: false, sign_off: nil, review_notes: nil,
|
|
43
|
-
location_ids: nil, client_mutation_id:
|
|
70
|
+
location_ids: nil, client_mutation_id: SecureRandom.uuid, **_kargs)
|
|
44
71
|
task = resolve_task(task, complete_page_task, sign_off, review_notes)
|
|
45
|
-
require_stage!(stage_id, submit, task)
|
|
46
72
|
page_input = (model.as_update || {}).slice(*PAGE_FIELD_KEYS)
|
|
47
73
|
# Page locations are read_only on the model (excluded from the as_update diff),
|
|
48
74
|
# so set them explicitly here → PageInput.locations = [LocationInput{ id }].
|
|
49
75
|
page_input = page_input.merge(locations: Array(location_ids).map { |lid| { id: lid } }) unless location_ids.nil?
|
|
50
76
|
data_fields = build_data_fields(model)
|
|
77
|
+
stage_id ||= derive_stage_id(model, data_fields)
|
|
78
|
+
require_stage!(stage_id, submit, task)
|
|
51
79
|
|
|
52
80
|
no_changes = page_input.empty? && data_fields.empty?
|
|
53
81
|
no_op = publish.nil? && submit.nil? && stage_id.nil? && task.nil?
|
|
@@ -66,13 +94,88 @@ module Ecoportal
|
|
|
66
94
|
|
|
67
95
|
private
|
|
68
96
|
|
|
97
|
+
# Derives the default `stageId` for a PHASED page update from the STAGE(S) that
|
|
98
|
+
# own the data fields being changed in THIS call (updates/additions/deletions).
|
|
99
|
+
#
|
|
100
|
+
# Platform fact, live-diagnosed against a customer's phased page (the
|
|
101
|
+
# org's own service account): `updatePage` on a PHASED page 500s when `stageId`
|
|
102
|
+
# is missing on an Image Gallery / File field write. The live-verified
|
|
103
|
+
# a customer collection's integration guide (§5) documents a related but
|
|
104
|
+
# narrower nuance — omitting `stageId` is judged against the ROOT page's
|
|
105
|
+
# `PagePolicy#update?` rather than being an unconditional failure, so it is
|
|
106
|
+
# "optional but preferable" for an account whose edit right arrives through
|
|
107
|
+
# page-level administration rather than a stage. Sending it whenever it CAN be
|
|
108
|
+
# derived is correct either way: never wrong for an account that did not need
|
|
109
|
+
# it, and fixes the account/field combination that does. A `BasicPage` has no
|
|
110
|
+
# `#stages` at all — returns `nil` (stageId OMITTED from the input entirely,
|
|
111
|
+
# never sent as an explicit `null`; `build_input`'s nil-guard already drops a
|
|
112
|
+
# nil operation key).
|
|
113
|
+
#
|
|
114
|
+
# Multi-stage calls: if the fields being updated in this ONE call span MORE
|
|
115
|
+
# THAN ONE stage, this raises rather than guessing which stage's id to send (a
|
|
116
|
+
# single `updatePage` input can only carry one `stageId`). The caller must split
|
|
117
|
+
# such a change into one `from_model`/mutation call PER STAGE, in stage order,
|
|
118
|
+
# threading the model's (fresh, server-returned) `patchVer` from each response
|
|
119
|
+
# into the next call — the same pattern the org-side reference implementation
|
|
120
|
+
# (a downstream script repo's `-file-attach` case, `persist_file_updates`/
|
|
121
|
+
# `persist_gallery`) already uses at the call-site level, re-fetching the page
|
|
122
|
+
# between groups. Pass `stage_id:` explicitly to bypass this derivation for any
|
|
123
|
+
# other need (e.g. targeting a stage submit with no dirty field on it yet).
|
|
124
|
+
def derive_stage_id(model, data_fields)
|
|
125
|
+
return nil unless model.respond_to?(:stages)
|
|
126
|
+
|
|
127
|
+
field_ids = changed_field_ids(data_fields)
|
|
128
|
+
return nil if field_ids.empty?
|
|
129
|
+
|
|
130
|
+
owning_stage_ids = Array(model.stages).filter_map do |stage|
|
|
131
|
+
stage.id if Array(stage.stage_field_ids).intersect?(field_ids)
|
|
132
|
+
end.uniq
|
|
133
|
+
|
|
134
|
+
case owning_stage_ids.size
|
|
135
|
+
when 0 then nil
|
|
136
|
+
when 1 then owning_stage_ids.first
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"This update's data fields span #{owning_stage_ids.size} different " \
|
|
140
|
+
"stages (#{owning_stage_ids.join(', ')}) — a single updatePage call " \
|
|
141
|
+
'can only target one stageId. Split into one from_model/mutation call ' \
|
|
142
|
+
"per stage, in stage order, re-fetching the page (fresh patchVer) " \
|
|
143
|
+
'between calls, or pass stage_id: explicitly to target just one.'
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Every field id touched by this call's dataFields command (updates, additions,
|
|
148
|
+
# deletions) — the raw DataFieldInput hashes are `{ typeKey: { id:, ... } }`, so
|
|
149
|
+
# `id` is read generically off the first (only) value, regardless of field type.
|
|
150
|
+
def changed_field_ids(data_fields)
|
|
151
|
+
ids = Array(data_fields[:updates]).filter_map { |cmd| field_id_from_command(cmd) }
|
|
152
|
+
ids.concat(Array(data_fields[:additions]).filter_map { |cmd| field_id_from_command(cmd) })
|
|
153
|
+
ids.concat(Array(data_fields[:deletions]))
|
|
154
|
+
ids.compact.uniq
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def field_id_from_command(command)
|
|
158
|
+
return nil unless command.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
inner = command.values.first
|
|
161
|
+
inner.is_a?(Hash) ? inner[:id] : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
69
164
|
# Platform invariant: a stage submit / sign-off MUST target a specific stage.
|
|
70
165
|
# Page tasks (and the people-field permissions scoped to them) apply to ONE
|
|
71
166
|
# stage; without a stageId the server cannot route the submit to the stage the
|
|
72
167
|
# work was done on. Fail loudly here rather than silently omit stageId (the
|
|
73
168
|
# nil-guard in build_input would otherwise drop it — the exact bug that let an
|
|
74
|
-
#
|
|
75
|
-
#
|
|
169
|
+
# some customer submissions go out stage-less).
|
|
170
|
+
#
|
|
171
|
+
# CORRECTED comment (this used to say "a plain field update ... needs no stage
|
|
172
|
+
# and is unaffected" — wrong for a PHASED page: `updatePage` 500s there without
|
|
173
|
+
# a `stageId` on ANY write, plain field update included; see `#derive_stage_id`,
|
|
174
|
+
# which is called BEFORE this guard and already fills it in from the fields
|
|
175
|
+
# being changed whenever it can). This guard now only fires for the case
|
|
176
|
+
# `#derive_stage_id` genuinely cannot resolve — no dirty field to derive from
|
|
177
|
+
# (a stage-submit-only call) or a `BasicPage` (no stages at all, where a submit
|
|
178
|
+
# makes no sense anyway) — same platform invariant, just reached less often now.
|
|
76
179
|
def require_stage!(stage_id, submit, task)
|
|
77
180
|
return unless stage_id.nil?
|
|
78
181
|
return unless submit || task
|
|
@@ -96,7 +96,7 @@ module Ecoportal
|
|
|
96
96
|
# A register FIELD key carries a `<type>.<hash>` shape (e.g. 'date.zab1bddc3');
|
|
97
97
|
# system/top-level keys (created_at, external_id, state) have no such prefix.
|
|
98
98
|
# Field filters need the nested `membranes.<type>` path — see
|
|
99
|
-
#
|
|
99
|
+
# the repo's internal docs
|
|
100
100
|
# ---------------------------------------------------------------------------
|
|
101
101
|
module MembranePath
|
|
102
102
|
module_function
|
|
@@ -6,7 +6,7 @@ module Ecoportal
|
|
|
6
6
|
# `WorkflowEditTemplateContainerUidInput { templateContainerUid: String! }`.
|
|
7
7
|
#
|
|
8
8
|
# Sets the template's CROSS-VERSION identity key. Semantics (verified against the
|
|
9
|
-
# backend on 2026-07-31 — see
|
|
9
|
+
# backend on 2026-07-31 — see the repo's internal docs):
|
|
10
10
|
#
|
|
11
11
|
# - Format `/\A[a-zA-Z0-9\-_.]{3,20}\z/` — org-scoped, human-authored.
|
|
12
12
|
# - Many versions of a template may share one uid, but only ONE may be published
|
|
@@ -17,7 +17,7 @@ module Ecoportal
|
|
|
17
17
|
# included in a copy (`include`) and whether the choice is locked against per-copy
|
|
18
18
|
# override (`enforce`). `enforce` is the least certain of these.
|
|
19
19
|
# Confirm against platform behaviour before relying on any of it; see
|
|
20
|
-
#
|
|
20
|
+
# the repo's internal docs.
|
|
21
21
|
module ManageCopyPageConfiguration
|
|
22
22
|
SCHEMA_VERSION = '20260819'.freeze
|
|
23
23
|
VALID_KEYS = %i[overridePageCreatorPermissions autoIncludeNewContent inclusionSettingsChanges].freeze
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Input
|
|
5
|
+
class WorkflowCommand
|
|
6
|
+
# `WorkflowMoveSectionToStageInput` (20260929) -- moves an existing section from one
|
|
7
|
+
# stage to another, optionally placed relative to an anchor section. Unlike
|
|
8
|
+
# `reorderSection` (same stage), the source and target stages are both required.
|
|
9
|
+
module MoveSectionToStage
|
|
10
|
+
SCHEMA_VERSION = '20260929'.freeze
|
|
11
|
+
VALID_KEYS = %i[sourceStageId targetStageId sectionId anchorId anchorPosition].freeze
|
|
12
|
+
ANCHOR_POSITIONS = AddViewableField::ANCHOR_POSITIONS
|
|
13
|
+
|
|
14
|
+
def self.build(source_stage_id:, target_stage_id:, section_id:, **kwargs)
|
|
15
|
+
position = kwargs[:anchorPosition]
|
|
16
|
+
unless position.nil? || ANCHOR_POSITIONS.include?(position.to_s)
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"Invalid anchorPosition #{position.inspect}: " \
|
|
19
|
+
"expected one of #{ANCHOR_POSITIONS.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
sourceStageId: source_stage_id,
|
|
24
|
+
targetStageId: target_stage_id,
|
|
25
|
+
sectionId: section_id
|
|
26
|
+
}.merge(kwargs.slice(:anchorId, :anchorPosition).compact)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -43,6 +43,7 @@ module Ecoportal
|
|
|
43
43
|
collapseSection: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::CollapseSection',
|
|
44
44
|
expandSection: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::ExpandSection',
|
|
45
45
|
reorderSection: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::ReorderSection',
|
|
46
|
+
moveSectionToStage: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::MoveSectionToStage',
|
|
46
47
|
# Task
|
|
47
48
|
addTask: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::AddTask',
|
|
48
49
|
removeTask: 'Ecoportal::API::GraphQL::Input::WorkflowCommand::RemoveTask',
|
|
@@ -275,6 +276,7 @@ require_relative 'workflow_command/edit_linked_helper'
|
|
|
275
276
|
require_relative 'workflow_command/remove_linked_helper'
|
|
276
277
|
require_relative 'workflow_command/add_viewable_field'
|
|
277
278
|
require_relative 'workflow_command/move_viewable_field'
|
|
279
|
+
require_relative 'workflow_command/move_section_to_stage'
|
|
278
280
|
require_relative 'workflow_command/remove_viewable_field'
|
|
279
281
|
require_relative 'workflow_command/field_config/action_list'
|
|
280
282
|
require_relative 'workflow_command/field_config/contractor_entities'
|
|
@@ -155,7 +155,7 @@ module Ecoportal
|
|
|
155
155
|
# location `graphql-client` deliberately sets via
|
|
156
156
|
# `set_backtrace(["#{filename}:#{line}"])`. With that gone there was no other
|
|
157
157
|
# way to see which query blew up. (Tracked upstream; see
|
|
158
|
-
#
|
|
158
|
+
# the repo's internal docs.)
|
|
159
159
|
#
|
|
160
160
|
# But the location was never actually destroyed. Ruby populates `.cause`
|
|
161
161
|
# automatically when you raise inside a rescue, so the original error — with the
|
|
@@ -108,6 +108,9 @@ module Ecoportal
|
|
|
108
108
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
109
109
|
embeds_one :pageCanUseDrafts,
|
|
110
110
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
111
|
+
# DEPRECATED (20260929): removed from the live schema by the smart-assistant
|
|
112
|
+
# consolidation; no longer selected by Fragment::Permissions, so always nil. Kept so
|
|
113
|
+
# callers get nil instead of NoMethodError.
|
|
111
114
|
embeds_one :pageSmartAssistantCanChat,
|
|
112
115
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
113
116
|
embeds_one :personSchemaCanAccessManager,
|
|
@@ -120,6 +123,9 @@ module Ecoportal
|
|
|
120
123
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
121
124
|
embeds_one :publicTemplatesCanManage,
|
|
122
125
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
126
|
+
# DEPRECATED (20260929): removed from the live schema by the smart-assistant
|
|
127
|
+
# consolidation; no longer selected by Fragment::Permissions, so always nil. Kept so
|
|
128
|
+
# callers get nil instead of NoMethodError.
|
|
123
129
|
embeds_one :registerAssistantCanChat,
|
|
124
130
|
klass: "Ecoportal::API::GraphQL::Model::AuthorizationResult", nullable: true
|
|
125
131
|
embeds_one :registerCanAccessRegisterTools,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# A force binding — `addBinding`/`editBinding`'s `referenceId` is the field (or field
|
|
7
|
+
# placeholder) the binding points at. `reference_id=` accepts either a raw id/String
|
|
8
|
+
# OR a `Field` (or any object answering `#ref`) so a caller can write
|
|
9
|
+
# `binding.reference_id = some_field` and let the SAME just-created-or-real-id
|
|
10
|
+
# resolution `Field#ref` already does apply here automatically — no separate
|
|
11
|
+
# "resolve the field id" step for the caller to remember.
|
|
12
|
+
class Binding
|
|
13
|
+
include Node
|
|
14
|
+
|
|
15
|
+
PLACEHOLDER_PREFIX = 'bnd'.freeze
|
|
16
|
+
|
|
17
|
+
BINDING_TYPE = 'field'.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :template, :force, :type
|
|
20
|
+
attr_accessor :name
|
|
21
|
+
|
|
22
|
+
def initialize(template:, force:, id: nil, name: nil, reference_id: nil, type: BINDING_TYPE)
|
|
23
|
+
@template = template
|
|
24
|
+
@force = force
|
|
25
|
+
@id = id
|
|
26
|
+
@name = name
|
|
27
|
+
@type = type
|
|
28
|
+
self.reference_id = reference_id
|
|
29
|
+
@snapshot = snapshot_attrs.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_writer :reference_id
|
|
33
|
+
|
|
34
|
+
# Resolved at read time: the referenced node's own `#ref` when it is one (a `Field`
|
|
35
|
+
# or anything else in this tree), otherwise the raw value as given.
|
|
36
|
+
def reference_id
|
|
37
|
+
@reference_id.respond_to?(:ref) ? @reference_id.ref : @reference_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dirty?
|
|
41
|
+
!removed? && !new? && snapshot_attrs != @snapshot
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def changed_attributes
|
|
45
|
+
return {} if new? || removed?
|
|
46
|
+
|
|
47
|
+
snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def snapshot_attrs
|
|
53
|
+
{ name: name, reference_id: reference_id }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|