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
data/README.gem.md ADDED
@@ -0,0 +1,53 @@
1
+ # ecoportal-api-graphql
2
+
3
+ A Ruby client for the ecoPortal GraphQL API: typed models for pages, templates, data fields and
4
+ registers, query and mutation helpers, and the workflow command bus for granular template edits.
5
+
6
+ This is the README shipped inside the published gem. The source repository keeps its own
7
+ developer README.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ gem 'ecoportal-api-graphql', require: %w[ecoportal/api-graphql]
13
+ ```
14
+
15
+ ```
16
+ $ bundle
17
+ # or: $ gem install ecoportal-api-graphql
18
+ ```
19
+
20
+ ## Requirements
21
+
22
+ | | version |
23
+ |---|---|
24
+ | Ruby | `>= 3.2.2` |
25
+ | `ecoportal-api` | `~> 0.10, >= 0.10.17` |
26
+ | `ecoportal-api-v2` | `~> 3.3, >= 3.3.5` |
27
+ | `graphlient` | `>= 0.9.0, < 0.10` |
28
+
29
+ ## Example: load and edit a template
30
+
31
+ ```ruby
32
+ api = Ecoportal::API::GraphQL.new(email: ..., pass: ..., org_id: ...)
33
+ template = Ecoportal::API::GraphQL::Model::Template.load(api, id: template_page_id)
34
+ template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
35
+ template.save!(api, simulate: false) # simulate: true is the default -- inspect first
36
+ ```
37
+
38
+ `save!` simulates by default: it reports the commands it would send without writing anything.
39
+ Pass `simulate: false` to apply them.
40
+
41
+ ## Optional extensions
42
+
43
+ Some capabilities are distributed separately as optional extensions and are not part of this
44
+ gem. The gem works fully without them; when an extension is installed, it is picked up
45
+ automatically.
46
+
47
+ ## Changes
48
+
49
+ See `CHANGELOG.md`, shipped alongside this file.
50
+
51
+ ## Licence
52
+
53
+ MIT -- see `LICENSE`.
@@ -25,7 +25,7 @@ module Ecoportal
25
25
  def session_token_renewed(host: server, refresh_token: nil)
26
26
  unless refresh_token
27
27
  return unless (body = session_token_data(host: host))
28
- return unless (refresh_token = body['resfresh_token'])
28
+ return unless (refresh_token = body['refresh_token'])
29
29
  end
30
30
 
31
31
  session_refresh_token_data(
@@ -56,12 +56,50 @@ module Ecoportal
56
56
  @org_id || fetch_env_required('ORGANIZATION_ID')
57
57
  end
58
58
 
59
+ # Graphlient/graphql-client build the client-side schema from the server's raw
60
+ # introspection JSON (GraphQL::Schema.from_introspection -> GraphQL::Schema::Loader).
61
+ # That loader defines one GraphQL::Schema::InputObject subclass per INPUT_OBJECT type,
62
+ # but it never calls `has_no_arguments(true)` on the ones whose `inputFields` came back
63
+ # empty. graphql-ruby then warns (and, in a future version, raises) the first time
64
+ # anything asks such a type for its arguments -- which happens whenever this schema is
65
+ # re-serialized (e.g. `schema.to_json`/`to_definition`, as the `-graphql-schema` use
66
+ # case in eco-helpers does). The live server never emits this warning because its own
67
+ # schema classes are defined directly in Ruby with real argument lists (or with
68
+ # `has_no_arguments(true)` already set) -- it is only the client-side schema, rebuilt
69
+ # purely from introspection data, that loses that annotation.
70
+ #
71
+ # Patching the built classes here (rather than pre-processing the introspection JSON)
72
+ # keeps the schema's shape untouched and uses graphql-ruby's own documented escape
73
+ # hatch for this exact situation.
74
+ def schema
75
+ mark_argumentless_input_objects!(super)
76
+ end
77
+
59
78
  private
60
79
 
61
80
  def url
62
81
  base_url = Ecoportal::API::Common::GraphQL::HttpClient.base_url(host)
63
82
  "#{base_url}/api/#{org_id}/#{ENDPOINT_PATH}"
64
83
  end
84
+
85
+ def mark_argumentless_input_objects!(loaded_schema)
86
+ return loaded_schema if @argumentless_input_objects_marked
87
+
88
+ graphql_schema_types(loaded_schema).each_value do |type|
89
+ next unless type.respond_to?(:kind) && type.kind.input_object?
90
+ next unless type.respond_to?(:has_no_arguments) && type.respond_to?(:any_arguments?)
91
+
92
+ type.has_no_arguments(true) unless type.any_arguments?
93
+ end
94
+ @argumentless_input_objects_marked = true
95
+
96
+ loaded_schema
97
+ end
98
+
99
+ def graphql_schema_types(loaded_schema)
100
+ target = loaded_schema.respond_to?(:graphql_schema) ? loaded_schema.graphql_schema : loaded_schema
101
+ target.types
102
+ end
65
103
  end
66
104
  end
67
105
  end
@@ -96,9 +96,11 @@ module Ecoportal
96
96
  end
97
97
  end
98
98
 
99
- ENDPOINT_PATH = 'external/graphql'.freeze
100
- READ_TIMEOUT = 90
101
- WRITE_TIMEOUT = 90
99
+ ENDPOINT_PATH = 'external/graphql'.freeze
100
+ CONNECT_TIMEOUT = 30
101
+ READ_TIMEOUT = 90
102
+ WRITE_TIMEOUT = 90
103
+ KEEP_ALIVE_TIMEOUT = 5
102
104
 
103
105
  attr_reader :host, :version
104
106
 
@@ -137,6 +139,36 @@ module Ecoportal
137
139
 
138
140
  # Creates a HTTP object adding the `X-ApiKey` or `X-ECOPORTAL-API-KEY` param to the header, depending on the API version.
139
141
  # @note It configures HTTP so it only allows body data in json format.
142
+ # @note This is the ONLY place in the gem's dependency chain that talks to the
143
+ # server via http.rb (`require 'http'`, pulled in by `Ecoportal::API::Common::Client`
144
+ # from the `ecoportal-api` gem) -- every live GraphQL query goes through `#execute`
145
+ # below, called from `Logic::BaseQuery#graphql_query` via `client.http_client.execute`,
146
+ # and `AuthService#auth_http_client` (`version: 'http'`) reuses this same
147
+ # `base_request` for the OAuth token POST. `Common::GraphQL::Client` (graphlient) is a
148
+ # separate, unrelated path: it never reaches http.rb at all.
149
+ #
150
+ # Without an explicit `connect:`, http.rb's `HTTP::Timeout::PerOperation` falls back
151
+ # to its own default of 0.25s for CONNECT even though read/write were already given
152
+ # explicitly (per_operation.rb: `options.fetch(:connect_timeout, CONNECT_TIMEOUT)`,
153
+ # `CONNECT_TIMEOUT = 0.25`). `connect_ssl` (`http/timeout/per_operation.rb`) uses
154
+ # that same `@connect_timeout` for the TLS handshake via `rescue_readable`/
155
+ # `rescue_writable` (`http/timeout/null.rb`) -- whose error text is unconditionally
156
+ # "Read timed out after #{@read_timeout} seconds", so a slow TLS handshake on the EU
157
+ # instance surfaced as `HTTP::TimeoutError: Read timed out after 0.25 seconds`
158
+ # even though `@read_timeout` itself was correctly 90 the whole time. Live-confirmed
159
+ # 2026-09-08 via backtrace: `connect_ssl` -> `start_tls` (`http/connection.rb`) ->
160
+ # `Common::Client#post` -> `AuthService#session_token_data` ->
161
+ # `Common::GraphQL::Client#initialize`. Read/write were never the problem; only
162
+ # connect was missing.
163
+ # @note The client is made PERSISTENT (`HTTP.persistent`) to `base_url`, not a
164
+ # plain one-shot client. Confirmed live 2026-09-08: eu.live.ecoportal.com
165
+ # closes an HTTP/1.1 request carrying `Connection: close` -- what a
166
+ # non-persistent http.rb client always sends -- without ever responding. A
167
+ # persistent client sends `Connection: keep-alive` and reuses the TCP/TLS
168
+ # connection instead, which the SAME Ruby stack proved fine against EU. This
169
+ # also matches Cloudflare keep-alive hygiene; Sydney is fine either way. One
170
+ # `HttpClient` is bound to one `host`, so `HTTP::StateError` (raised only on a
171
+ # cross-origin request against a persistent client) cannot occur here.
140
172
  # @return [HTTP] HTTP object.
141
173
  def base_request
142
174
  @base_request ||=
@@ -151,9 +183,10 @@ module Ecoportal
151
183
  HTTP.headers('Authorization' => "Bearer #{session_token(host: host)}")
152
184
  end.then do |request|
153
185
  request ||= HTTP
154
- request.accept(:json).timeout(
155
- read: READ_TIMEOUT,
156
- write: WRITE_TIMEOUT
186
+ request.persistent(base_url, timeout: KEEP_ALIVE_TIMEOUT).accept(:json).timeout(
187
+ connect: CONNECT_TIMEOUT,
188
+ read: READ_TIMEOUT,
189
+ write: WRITE_TIMEOUT
157
190
  )
158
191
  end
159
192
  end
@@ -15,7 +15,7 @@ module Ecoportal::API::Common::GraphQL::Model::Diffable
15
15
  # `CollectionModel` — neither is a `GraphQL::Model`, so the re-add never happens and
16
16
  # the array change becomes **invisible to `as_update`** (scalar `passthrough` fields
17
17
  # are unaffected). This is the systemic "array/leaf fields don't diff" root cause
18
- # (see `.ai-assistance/code/model_input_mapping/SYNTHESIS.md` §3.1).
18
+ # (see the repo's internal docs §3.1).
19
19
  #
20
20
  # This service fixes it for leaf models by:
21
21
  # 1. keeping cascaded keys in the flat classic diff (`doc_with_non_cascaded_attributes`
@@ -31,7 +31,7 @@ module Ecoportal::API::GraphQL::Base::Page
31
31
 
32
32
  # Find first field matching label (case-insensitive). Optional type: restricts to
33
33
  # that field type first (v2 compat: components.get_by_name(name, type:) — used by
34
- # the farmers cutover use case which calls it directly, not via OozeRedirect).
34
+ # a customer cutover use case which calls it directly, not via OozeRedirect).
35
35
  def get_by_name(label, type: nil)
36
36
  pool = type ? get_by_type(type) : @fields
37
37
  pool.find { |f| f.label.to_s.downcase == label.to_s.downcase }
@@ -1,26 +1,174 @@
1
1
  module Ecoportal::API::GraphQL::Base::Page
2
2
  class DataField
3
3
  # Image gallery field.
4
- # ★ WRITE PATH BROKEN (schema-invalid, live-verified 2026-08): `ImageGalleryInput` has no
5
- # `fileContainerIds` — it takes `images: [ImageInput]`, attaching via `sourceId`, which is
6
- # an `Enzyme::ImageReference::TempImage` (a DIFFERENT upload pipeline than file containers).
7
- # Fixing this needs its own design — see the DataField shape-asymmetry review project.
8
- # The reader is equally stale: live responses carry `images`, not `fileContainers`.
4
+ #
5
+ # ★ FIXED 2026-09 (previously broken -- see `spec/.../image_gallery_characterization_spec.rb`
6
+ # git history for the prior "WRITE PATH BROKEN" documentation of this defect). Confirmed
7
+ # against the LIVE ecoPortal server source (read-only, /tmp/work/ecoPortal) that a
8
+ # gallery image is genuinely NOT a `FileContainer`:
9
+ # * `app/graphql/types/fill_in_page/inputs/data_fields/image_gallery_input.rb:5-11` --
10
+ # `ImageGalleryInput` has NO `fileContainerIds`; it takes `images: [ImageInput]`.
11
+ # * `.../image_galleries/image_input.rb:6-15` -- `ImageInput` takes `sourceId` (a
12
+ # `TempImage` id, from the SEPARATE `uploadImage` mutation -- see
13
+ # `Mutation::Image::Upload` / `FileUpload::Client#upload_image`), never
14
+ # `fileContainerId`.
15
+ # * `app/services/new_ep/pages/assign_attributes_service.rb:152-176`
16
+ # (`set_image_gallery_attrs`) -- full-replace-by-omission, the SAME trap as
17
+ # `FileField`: any current image whose `id` is not echoed back in the write is
18
+ # destroyed (`format_removed_ids`).
19
+ # * `app/graphql/types/pages/data_fields/image_galleries/image_type.rb` -- `fileName`/
20
+ # `fileSize` are NULLABLE even on a successful upload -- a documented upstream bug
21
+ # ("images are successfully uploaded but the file name and size are not set in the
22
+ # database", per the live schema's own field comment). Any comparison built on these
23
+ # two fields must treat a nil on either side as "not a confident match", never a
24
+ # positive one.
25
+ #
26
+ # READ: `images { id weight downloadUrl caption fileName fileSize uploadId }`
27
+ # (`imageGalleryField` fragment, `fragment/pages/common_page_union.rb` -- `id`/`weight`
28
+ # were ADDED alongside this fix; the fragment previously omitted both, which is why no
29
+ # correct reader was possible before now).
30
+ #
31
+ # WRITE: `ImageGalleryInput { id images: [ImageInput] }`; `ImageInput { id weight
32
+ # sourceId caption fileName sensitiveContent inaccurateDescription
33
+ # inaccurateExtractedText }`. `as_input` sends every KEPT image back with its FULL
34
+ # `ImageInput` field set (`#kept_image_input`) -- NOT a bare `{id:}` -- and every NEW one
35
+ # as `{sourceId:, weight:, fileName:}` -- see `#add_source_images`, the merge-safe writer
36
+ # (mirrors `FileField#file_container_ids=`'s kept-vs-new split).
37
+ #
38
+ # ★ CORRECTED 2026-09 (pre-release, MR !278 unreleased): the bare `{id:}` echo for a kept
39
+ # image, above, silently dropped every OTHER attribute on that image on the NEXT write
40
+ # server-side -- `assign_attributes_service.rb#set_image_gallery_attrs` applies `images`
41
+ # as a FULL REPLACE (see the class header above), so a bare `{id:}` is indistinguishable
42
+ # from "clear caption/sensitiveContent/etc back to their defaults" for that image, not
43
+ # "leave it as it is". Matches the org-side reference implementation (a downstream script repo,
44
+ # commit 482db9c) and the live schema's own `ImageInput` argument list -- the shipped
45
+ # `ep-api-collections` sample only demonstrates the NEW-image shape (no existing images to
46
+ # keep), so this shape is pinned to server source + that proven reference, not a captured
47
+ # kept-echo payload (see EVIDENCE PRECEDENCE note on `#kept_image_input`).
48
+ #
49
+ # `images` is deliberately a PLAIN doc reader, not `passarray` -- same reasoning as
50
+ # `FileField#items` (see that class's header): materialising an ArrayModel writes
51
+ # `images: []` into the doc of a field read without `$content`, and a phantom-dirty empty
52
+ # `images` here doesn't always no-op (only the empty-vs-missing-key case is neutralised
53
+ # by `Diffable::LeafDiffService`'s array-diff fix, MR !106) -- it can still synthesise an
54
+ # unwanted write for a non-empty phantom read. A plain reader has no such hazard.
9
55
  class ImageGallery < DataField
10
- passarray :fileContainers # each: { id:, fileName:, url:, ... }
56
+ # each: { 'id' =>, 'weight' =>, 'downloadUrl' =>, 'caption' =>, 'fileName' =>,
57
+ # 'fileSize' =>, 'uploadId' => }
58
+ def images
59
+ Array(doc['images'])
60
+ end
61
+
62
+ # @return [Array<String>] every image's own item id (its server-assigned id, NOT the
63
+ # `uploadId`/`TempImage` id it was created from).
64
+ def image_ids
65
+ images.filter_map { |image| image['id'] if image.is_a?(Hash) }
66
+ end
11
67
 
12
- def file_container_ids
13
- Array(fileContainers).map { |c| c.is_a?(Hash) ? c['id'] : c }.compact
68
+ # @return [Integer] one past the highest `weight` currently on the field (`0` if the
69
+ # field is empty). There is no server-side auto-increment for `weight` (a plain
70
+ # Integer field, default `0`) -- callers that want sequential ordering for several new
71
+ # images in one write should still call this ONCE and increment locally
72
+ # (`#add_source_images` already does).
73
+ def next_weight
74
+ return 0 if images.empty?
75
+
76
+ images.map { |image| image.is_a?(Hash) ? image['weight'].to_i : 0 }.max + 1
14
77
  end
15
78
 
16
- def file_container_ids=(ids)
17
- doc['fileContainers'] = Array(ids).map { |id_val| { 'id' => id_val } }
79
+ # Merge-safe write: adds NEW images (each referenced by `source_id`, the `TempImage`
80
+ # id from `FileUpload::Client#upload_image`) while KEEPING every image already loaded
81
+ # on this field. `images` on `ImageGalleryInput` is a FULL REPLACE server-side -- any
82
+ # current image whose `id` is not echoed back is destroyed -- so this always keeps
83
+ # every currently-loaded image's FULL doc entry (untouched -- `#images_input` picks the
84
+ # `ImageInput`-writable subset out of it later; kept HERE in full so that subset still
85
+ # has every field to read, rather than collapsing to a bare `{'id' => ...}` that would
86
+ # throw the other attributes away before `#as_input` ever runs) and appends the new
87
+ # ones after it, at sequential weights starting at `#next_weight`. Marks the field dirty
88
+ # (same underlying mechanism as `FileField#file_container_ids=` -- a raw `doc` mutation,
89
+ # picked up by the generic diff regardless of the `passarray`/plain-reader choice above).
90
+ #
91
+ # @param sources [Array<Hash>] one entry per NEW image. Accepts either key style:
92
+ # `{source_id:, file_name:}` or `{'sourceId' =>, 'fileName' =>}`. `weight` is assigned
93
+ # here, not read from the input -- the caller does not need to compute it.
94
+ # @return [Array<Hash>] the full resulting `images` doc (kept + added), same value now
95
+ # readable via `#images`.
96
+ def add_source_images(sources)
97
+ kept = images.select { |image| image.is_a?(Hash) && image['id'] }
98
+ start = next_weight
99
+ added = Array(sources).each_with_index.map do |source, index|
100
+ {
101
+ 'sourceId' => fetch_key(source, :source_id, 'sourceId'),
102
+ 'weight' => start + index,
103
+ 'fileName' => fetch_key(source, :file_name, 'fileName')
104
+ }.compact
105
+ end
106
+ doc['images'] = kept + added
18
107
  end
19
108
 
20
109
  def as_input
21
110
  return nil unless dirty?
22
111
 
23
- { imageGallery: { id: id, fileContainerIds: file_container_ids } }
112
+ {imageGallery: {id: id, images: images_input}}
113
+ end
114
+
115
+ private
116
+
117
+ # `images` holds the write shape for a NEW item (`{'sourceId' =>, 'weight' =>,
118
+ # 'fileName' =>}`, from `#add_source_images`) as-is, and the FULL read doc for a KEPT
119
+ # item (untouched since the last read) -- `#kept_image_input` narrows the latter down to
120
+ # exactly what `ImageInput` accepts.
121
+ def images_input
122
+ images.filter_map do |image|
123
+ next unless image.is_a?(Hash)
124
+
125
+ if image['sourceId']
126
+ {sourceId: image['sourceId'], weight: image['weight'], fileName: image['fileName']}.compact
127
+ elsif image['id']
128
+ kept_image_input(image)
129
+ end
130
+ end
131
+ end
132
+
133
+ # Full-field echo for a KEPT (currently-existing, untouched) image -- REQUIRED because
134
+ # `ImageGalleryInput.images` is a full replace server-side (`assign_attributes_service.
135
+ # rb#set_image_gallery_attrs` -> `format_removed_ids`): a bare `{id:}` is read by the
136
+ # server as "clear every other attribute", not "leave this image as it is".
137
+ #
138
+ # EVIDENCE PRECEDENCE (shipped sample -> server source -> gem model): the shipped
139
+ # a customer collection's "Attach Image to Gallery Field"
140
+ # sample only demonstrates a BRAND-NEW image (`sourceId`/`weight`/`fileName`, no
141
+ # existing images on the field to keep) -- it does not capture a kept-image echo. This
142
+ # shape is therefore pinned to the next level down: the live schema's own `ImageInput`
143
+ # argument list (`app/graphql/types/fill_in_page/inputs/data_fields/image_galleries/
144
+ # image_input.rb`: `id weight sourceId caption fileName sensitiveContent
145
+ # inaccurateDescription inaccurateExtractedText`) plus the org-side reference
146
+ # implementation already built against those same server facts and merged
147
+ # (a downstream script repo, commit 482db9c, `#kept_gallery_image_input`) -- not an inference
148
+ # from this gem's own prior (incorrect) behaviour.
149
+ #
150
+ # `sourceId` here is read back from the CURRENT read's `uploadId` -- the read-side name
151
+ # for the same identifier (a customer collection's integration guide
152
+ # section 7: "the id changes name between reading and writing"). Every key is sent as
153
+ # read (never `.compact`-ed away) -- this is an ECHO of the field's current state, so a
154
+ # nil here means "this attribute is genuinely unset", which is exactly what should be
155
+ # (re-)written to preserve it unset, not omitted and misread as "clear it".
156
+ def kept_image_input(image)
157
+ {
158
+ id: image['id'],
159
+ sourceId: image['uploadId'],
160
+ weight: image['weight'],
161
+ caption: image['caption'],
162
+ fileName: image['fileName'],
163
+ sensitiveContent: image['sensitiveContent'],
164
+ inaccurateDescription: image['inaccurateDescription'],
165
+ inaccurateExtractedText: image['inaccurateExtractedText']
166
+ }
167
+ end
168
+
169
+ def fetch_key(hash, *keys)
170
+ keys.each { |k| return hash[k] if hash.is_a?(Hash) && hash.key?(k) }
171
+ nil
24
172
  end
25
173
  end
26
174
  end
@@ -0,0 +1,28 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # The upload-staging model behind `uploadImage` -- explicitly NOT a `FileContainer`.
6
+ # Verified against the live ecoPortal server schema (read-only,
7
+ # /tmp/work/ecoPortal): `Types::Files::TempImageType` (`app/graphql/types/files/
8
+ # temp_image_type.rb`) declares exactly `id s3Key error complete`, backed by
9
+ # `Enzyme::ImageReference::TempImage` (`app/models/enzyme/image_reference.rb`), a
10
+ # separate upload-staging Mongoid model from `FileContainer`.
11
+ #
12
+ # `complete` starts `false` immediately after `uploadImage` returns and is filled in
13
+ # later by background processing. A consumer does NOT need to wait for it: confirmed
14
+ # server-side (`app/services/new_ep/pages/assign_attributes_service.rb`'s
15
+ # `set_image_gallery_attrs`) that attaching a `TempImage` to an Image Gallery field via
16
+ # `ImageInput.sourceId` succeeds whether or not `complete?`/`error?` is true yet -- an
17
+ # incomplete image is accepted and filled in on the record later.
18
+ class TempImage < Logic::BaseModel
19
+ read_only!
20
+
21
+ passkey :id
22
+ passthrough :s3Key, :error
23
+ passboolean :complete
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -15,6 +15,7 @@ require_relative 'base/location_structure'
15
15
  require_relative 'base/person_member'
16
16
  require_relative 'base/file_attachment'
17
17
  require_relative 'base/file_container'
18
+ require_relative 'base/temp_image'
18
19
  require_relative 'base/field'
19
20
  require_relative 'base/page'
20
21
  require_relative 'base/resource'
@@ -9,12 +9,17 @@ module Ecoportal
9
9
  @client = client
10
10
  end
11
11
 
12
- def create(commands:, &block)
13
- createMutation.query(commands: commands, &block)
12
+ # @param register_id [String, nil] bind the new template to this register. Optional in
13
+ # the schema, but in practice REQUIRED for the create to be authorized at all — see
14
+ # the WHY in {Mutation::Template::Create}. Omitted when nil.
15
+ def create(commands:, register_id: nil, &block)
16
+ createMutation.query(commands: commands, register_id: register_id, &block)
14
17
  end
15
18
 
16
- def update(model, commands:, patch_ver: model.patchVer, &block)
17
- updateMutation.query(id: model.id, patch_ver: patch_ver, commands: commands, &block)
19
+ def update(model, commands:, patch_ver: model.patchVer, client_mutation_id: nil, &block)
20
+ kwargs = { id: model.id, patch_ver: patch_ver, commands: commands }
21
+ kwargs[:client_mutation_id] = client_mutation_id unless client_mutation_id.nil?
22
+ updateMutation.query(**kwargs, &block)
18
23
  end
19
24
 
20
25
  def publish(id:, &block)
@@ -18,7 +18,7 @@ module Ecoportal
18
18
  # tag_filter passed through (same ES backend, same format)
19
19
  # register_filter passed through
20
20
  # and_filter / or_filter passed through
21
- # Operation/param shapes per .ai-assistance/code/search_filters.md.
21
+ # Operation/param shapes per the repo's internal docs
22
22
  class FilterTranslator
23
23
  class << self
24
24
  def to_graphql(filter)