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
@@ -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
- # Single file:
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
- # Many files, concurrently, with per-file error isolation (nothing raises out):
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 "container #{payload.item&.id}" }
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 = 'AES256'.freeze
55
- DEFAULT_MIME = 'application/octet-stream'.freeze
56
- MAX_THREADS = 8
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
- files = Array(file_paths).flatten.compact
112
- max = threads.to_i.clamp(1, MAX_THREADS)
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
- spawned.each(&:join)
131
- results
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
- raise MissingLocalFile, "no such file: #{file_path}" unless ::File.file?(file_path)
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, result.key,
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
- # `.ai-assistance/code/ecoPortal_architecture/02_data_model.md`.
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
- # M&S do so on page instances — confirmed working on instances with a
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 @skip(if: $only_content)
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 (required for stage submit/close-out)
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: '', **_kargs)
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
- # act-gov TOOCS submit go out stage-less). A plain field update or publish
75
- # needs no stage and is unaffected.
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
- # .ai-assistance/code/filter_contract_matrix.md.
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 `.ai-assistance/code/ecoPortal_architecture/02_data_model.md`):
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
- # `.ai-assistance/code/ecoPortal_architecture/10_forces_workflow_builder.md`.
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
- # `.ai-assistance/code/refactoring/opportunities.md`.)
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,10 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ class TempImage < Base::TempImage
6
+ end
7
+ end
8
+ end
9
+ end
10
+ end
@@ -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