ecoportal-api-graphql 1.3.17 → 2.2.1

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 (177) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +763 -11
  3. data/lib/ecoportal/api/common/graphql/http_client.rb +85 -2
  4. data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +97 -0
  5. data/lib/ecoportal/api/common/graphql/model/diffable.rb +1 -0
  6. data/lib/ecoportal/api/graphql/base/action.rb +1 -1
  7. data/lib/ecoportal/api/graphql/base/action_event.rb +23 -0
  8. data/lib/ecoportal/api/graphql/base/action_event_page.rb +16 -0
  9. data/lib/ecoportal/api/graphql/base/activity.rb +38 -0
  10. data/lib/ecoportal/api/graphql/base/activity_log.rb +38 -0
  11. data/lib/ecoportal/api/graphql/base/ai_summary_version.rb +12 -7
  12. data/lib/ecoportal/api/graphql/base/auth_log.rb +24 -0
  13. data/lib/ecoportal/api/graphql/base/authorization_result.rb +20 -0
  14. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +17 -5
  15. data/lib/ecoportal/api/graphql/base/file_container.rb +12 -4
  16. data/lib/ecoportal/api/graphql/base/location_classification_type.rb +2 -0
  17. data/lib/ecoportal/api/graphql/base/location_structure.rb +3 -0
  18. data/lib/ecoportal/api/graphql/base/minimal_dashboard.rb +25 -0
  19. data/lib/ecoportal/api/graphql/base/organization.rb +2 -0
  20. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +25 -2
  21. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +46 -7
  22. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +5 -2
  23. data/lib/ecoportal/api/graphql/base/page/data_field/people.rb +50 -12
  24. data/lib/ecoportal/api/graphql/base/page/data_field.rb +12 -18
  25. data/lib/ecoportal/api/graphql/base/page/phased/stage.rb +51 -0
  26. data/lib/ecoportal/api/graphql/base/permissions.rb +15 -0
  27. data/lib/ecoportal/api/graphql/base/person_member_changelog.rb +23 -0
  28. data/lib/ecoportal/api/graphql/base/preset_view.rb +135 -0
  29. data/lib/ecoportal/api/graphql/base/register.rb +5 -0
  30. data/lib/ecoportal/api/graphql/base/template/change_log_entry.rb +118 -0
  31. data/lib/ecoportal/api/graphql/base/template.rb +2 -0
  32. data/lib/ecoportal/api/graphql/base.rb +9 -0
  33. data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +10 -0
  34. data/lib/ecoportal/api/graphql/builder/page.rb +0 -16
  35. data/lib/ecoportal/api/graphql/builder/template.rb +46 -0
  36. data/lib/ecoportal/api/graphql/builder/template_builder.rb +21 -2
  37. data/lib/ecoportal/api/graphql/compat/pages.rb +7 -2
  38. data/lib/ecoportal/api/graphql/compat/response.rb +14 -0
  39. data/lib/ecoportal/api/graphql/concerns/threadable.rb +58 -0
  40. data/lib/ecoportal/api/graphql/concerns.rb +1 -0
  41. data/lib/ecoportal/api/graphql/connection/action.rb +1 -1
  42. data/lib/ecoportal/api/graphql/connection/action_category.rb +1 -1
  43. data/lib/ecoportal/api/graphql/connection/activity.rb +11 -0
  44. data/lib/ecoportal/api/graphql/connection/activity_log.rb +11 -0
  45. data/lib/ecoportal/api/graphql/connection/auth_log.rb +11 -0
  46. data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +1 -1
  47. data/lib/ecoportal/api/graphql/connection/minimal_dashboard.rb +11 -0
  48. data/lib/ecoportal/api/graphql/connection/page.rb +1 -1
  49. data/lib/ecoportal/api/graphql/connection/pages_workflow_command.rb +1 -1
  50. data/lib/ecoportal/api/graphql/connection/person_member.rb +1 -1
  51. data/lib/ecoportal/api/graphql/connection/person_member_changelog.rb +11 -0
  52. data/lib/ecoportal/api/graphql/connection/preview_page.rb +1 -1
  53. data/lib/ecoportal/api/graphql/connection/template_change_log.rb +19 -0
  54. data/lib/ecoportal/api/graphql/connection.rb +6 -0
  55. data/lib/ecoportal/api/graphql/diff/change.rb +7 -2
  56. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +19 -1
  57. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +21 -1
  58. data/lib/ecoportal/api/graphql/diff/version_diff.rb +13 -6
  59. data/lib/ecoportal/api/graphql/file_upload/client.rb +198 -139
  60. data/lib/ecoportal/api/graphql/fragment/activity.rb +84 -0
  61. data/lib/ecoportal/api/graphql/fragment/activity_log.rb +75 -0
  62. data/lib/ecoportal/api/graphql/fragment/auth_log.rb +29 -0
  63. data/lib/ecoportal/api/graphql/fragment/authorization_result.rb +14 -0
  64. data/lib/ecoportal/api/graphql/fragment/location_node.rb +1 -0
  65. data/lib/ecoportal/api/graphql/fragment/minimal_dashboard.rb +31 -0
  66. data/lib/ecoportal/api/graphql/fragment/page.rb +1 -0
  67. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +23 -0
  68. data/lib/ecoportal/api/graphql/fragment/permissions.rb +96 -0
  69. data/lib/ecoportal/api/graphql/fragment/person_member_changelog.rb +33 -0
  70. data/lib/ecoportal/api/graphql/fragment/template_change_log.rb +102 -0
  71. data/lib/ecoportal/api/graphql/fragment.rb +8 -0
  72. data/lib/ecoportal/api/graphql/input/action/update.rb +12 -0
  73. data/lib/ecoportal/api/graphql/input/contractor_entity/manager_settings.rb +20 -0
  74. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +6 -27
  75. data/lib/ecoportal/api/graphql/input/contractor_entity.rb +1 -0
  76. data/lib/ecoportal/api/graphql/input/date_range.rb +16 -0
  77. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +2 -2
  78. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +2 -2
  79. data/lib/ecoportal/api/graphql/input/location_structure/insert_command.rb +8 -0
  80. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +6 -2
  81. data/lib/ecoportal/api/graphql/input/page/review_task.rb +14 -1
  82. data/lib/ecoportal/api/graphql/input/preset_view/update.rb +16 -1
  83. data/lib/ecoportal/api/graphql/input/workflow_command/add_checklist_field_item.rb +19 -0
  84. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +10 -9
  85. data/lib/ecoportal/api/graphql/input/workflow_command/add_select_field_option.rb +11 -5
  86. data/lib/ecoportal/api/graphql/input/workflow_command/add_smart_fill_config.rb +23 -0
  87. data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +3 -2
  88. data/lib/ecoportal/api/graphql/input/workflow_command/add_viewable_field.rb +45 -0
  89. data/lib/ecoportal/api/graphql/input/workflow_command/archive_public_config.rb +19 -0
  90. data/lib/ecoportal/api/graphql/input/workflow_command/create_public_config.rb +19 -0
  91. data/lib/ecoportal/api/graphql/input/workflow_command/edit_checklist_field_item.rb +19 -0
  92. data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +17 -2
  93. data/lib/ecoportal/api/graphql/input/workflow_command/edit_force.rb +5 -2
  94. data/lib/ecoportal/api/graphql/input/workflow_command/edit_public_config.rb +19 -0
  95. data/lib/ecoportal/api/graphql/input/workflow_command/edit_select_field_option.rb +6 -2
  96. data/lib/ecoportal/api/graphql/input/workflow_command/edit_smart_fill_config.rb +21 -0
  97. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_advanced_settings.rb +19 -0
  98. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_basic_settings.rb +32 -0
  99. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +45 -0
  100. data/lib/ecoportal/api/graphql/input/workflow_command/{edit_page_creator_permissions.rb → edit_template_creator_permissions.rb} +3 -2
  101. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_descriptions.rb +19 -0
  102. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/action_list.rb +31 -0
  103. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/contractor_entities.rb +6 -2
  104. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +9 -1
  105. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +15 -2
  106. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/file.rb +21 -0
  107. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +3 -2
  108. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/people.rb +27 -3
  109. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +9 -2
  110. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +3 -2
  111. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +3 -2
  112. data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +47 -0
  113. data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +3 -2
  114. data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
  115. data/lib/ecoportal/api/graphql/input/workflow_command/move_viewable_field.rb +33 -0
  116. data/lib/ecoportal/api/graphql/input/workflow_command/remove_checklist_field_item.rb +19 -0
  117. data/lib/ecoportal/api/graphql/input/workflow_command/remove_smart_fill_config.rb +19 -0
  118. data/lib/ecoportal/api/graphql/input/workflow_command/remove_viewable_field.rb +21 -0
  119. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +3 -2
  120. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +3 -2
  121. data/lib/ecoportal/api/graphql/input/workflow_command.rb +48 -5
  122. data/lib/ecoportal/api/graphql/input.rb +1 -0
  123. data/lib/ecoportal/api/graphql/interface/base_page.rb +50 -3
  124. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +2 -1
  125. data/lib/ecoportal/api/graphql/logic/base_query.rb +72 -5
  126. data/lib/ecoportal/api/graphql/logic/connection.rb +17 -0
  127. data/lib/ecoportal/api/graphql/logic/input.rb +127 -0
  128. data/lib/ecoportal/api/graphql/model/action_event.rb +14 -0
  129. data/lib/ecoportal/api/graphql/model/action_event_page.rb +10 -0
  130. data/lib/ecoportal/api/graphql/model/activity.rb +14 -0
  131. data/lib/ecoportal/api/graphql/model/activity_log.rb +31 -0
  132. data/lib/ecoportal/api/graphql/model/auth_log.rb +11 -0
  133. data/lib/ecoportal/api/graphql/model/authorization_result.rb +12 -0
  134. data/lib/ecoportal/api/graphql/model/minimal_dashboard.rb +14 -0
  135. data/lib/ecoportal/api/graphql/model/organization.rb +36 -0
  136. data/lib/ecoportal/api/graphql/model/page/phased.rb +17 -0
  137. data/lib/ecoportal/api/graphql/model/permissions.rb +185 -0
  138. data/lib/ecoportal/api/graphql/model/person_member.rb +7 -0
  139. data/lib/ecoportal/api/graphql/model/person_member_changelog.rb +15 -0
  140. data/lib/ecoportal/api/graphql/model/template/change_log_entry.rb +19 -0
  141. data/lib/ecoportal/api/graphql/model/template.rb +4 -1
  142. data/lib/ecoportal/api/graphql/model.rb +9 -0
  143. data/lib/ecoportal/api/graphql/mutation/ai_summary/generate.rb +20 -10
  144. data/lib/ecoportal/api/graphql/mutation/ai_summary/submit_feedback.rb +25 -10
  145. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update_manager_settings.rb +37 -0
  146. data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +1 -0
  147. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +78 -0
  148. data/lib/ecoportal/api/graphql/mutation/file_container.rb +15 -0
  149. data/lib/ecoportal/api/graphql/mutation/template/update_related_page.rb +79 -0
  150. data/lib/ecoportal/api/graphql/mutation/template.rb +1 -0
  151. data/lib/ecoportal/api/graphql/mutation.rb +1 -1
  152. data/lib/ecoportal/api/graphql/payload/contractor_entity/manager_settings.rb +20 -0
  153. data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +1 -0
  154. data/lib/ecoportal/api/graphql/payload/kickstand/bulk_update_jobs.rb +2 -2
  155. data/lib/ecoportal/api/graphql/payload/kickstand/bulk_update_workflows.rb +2 -2
  156. data/lib/ecoportal/api/graphql/payload/template/update_related_page.rb +15 -0
  157. data/lib/ecoportal/api/graphql/payload/template.rb +1 -0
  158. data/lib/ecoportal/api/graphql/payload/upload.rb +12 -0
  159. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  160. data/lib/ecoportal/api/graphql/query/activity_logs.rb +78 -0
  161. data/lib/ecoportal/api/graphql/query/auth_logs.rb +51 -0
  162. data/lib/ecoportal/api/graphql/query/dashboard_search.rb +57 -0
  163. data/lib/ecoportal/api/graphql/query/location_structure.rb +2 -0
  164. data/lib/ecoportal/api/graphql/query/location_structures.rb +2 -0
  165. data/lib/ecoportal/api/graphql/query/page.rb +6 -1
  166. data/lib/ecoportal/api/graphql/query/page_activities.rb +56 -0
  167. data/lib/ecoportal/api/graphql/query/permissions.rb +35 -0
  168. data/lib/ecoportal/api/graphql/query/person_member_changelogs.rb +53 -0
  169. data/lib/ecoportal/api/graphql/query/person_members.rb +76 -0
  170. data/lib/ecoportal/api/graphql/query/template_change_log.rb +118 -0
  171. data/lib/ecoportal/api/graphql/query.rb +8 -0
  172. data/lib/ecoportal/api/graphql_version.rb +1 -1
  173. metadata +83 -14
  174. data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +0 -28
  175. data/lib/ecoportal/api/graphql/mutation/smart_fill/generate.rb +0 -36
  176. data/lib/ecoportal/api/graphql/mutation/smart_fill/submit_feedback.rb +0 -40
  177. data/lib/ecoportal/api/graphql/mutation/smart_fill.rb +0 -13
@@ -2,7 +2,88 @@ module Ecoportal
2
2
  module API
3
3
  module Common
4
4
  module GraphQL
5
- class ResponseError < StandardError; end
5
+ # Raised when a 200 response carries a top-level `errors[]` array.
6
+ #
7
+ # ★ Carries the RAW error entries, so `extensions` survives. Before this, the
8
+ # class collapsed the response into the joined `message` text and threw the rest
9
+ # away — which made it impossible for Ruby tooling to tell a permission problem
10
+ # from a request problem, the exact diagnosis we tell customers to perform.
11
+ # Tracked as Q-27 in `docs/open-questions.md`.
12
+ #
13
+ # The backend's error contract (`app/graphql/eco_portal_schema.rb`, verified
14
+ # 2026-09-01) puts these keys under `extensions`:
15
+ #
16
+ # | key | when | shape |
17
+ # |----------------|--------------------------|-------|
18
+ # | `code` | always | string: `unauthorized`, `document_not_found`, `unprocessable_entity`, `write_conflict`, `internal_server_error`, … |
19
+ # | `fullMessages` | `unauthorized` | array of human strings (`exp.result.reasons.full_messages`) |
20
+ # | `details` | `unauthorized` | ActionPolicy `reasons.details` — usually a Hash whose **key names the failing policy** and whose value lists the rules |
21
+ # | `problems` | validation, and only when the server runs with `SHOW_GRAPHQL_EXCEPTION` | record errors |
22
+ #
23
+ # ⚠ `details` is NOT reliably a Hash. Two call sites emit an Array instead
24
+ # (`mutations/pages/send_as_email.rb` sends inaccessible ids;
25
+ # `queries/pages/templates/fetcher.rb` sends `[msg]`). Accessors here therefore
26
+ # return whatever the server sent, unwrapped but uncoerced — callers must not
27
+ # assume a shape.
28
+ class ResponseError < StandardError
29
+ # @return [Array<Hash>] the raw `errors[]` entries, exactly as received.
30
+ attr_reader :errors
31
+
32
+ # @param msg [String, nil] explicit message; defaults to the joined error messages.
33
+ # @param errors [Array<Hash>] raw top-level `errors[]` entries.
34
+ def initialize(msg = nil, errors: [])
35
+ @errors = Array(errors)
36
+ super(msg || build_message)
37
+ end
38
+
39
+ # @return [Array<Hash>] the `extensions` object of each error that carried one.
40
+ def extensions
41
+ errors.filter_map { |err| err['extensions'] }
42
+ end
43
+
44
+ # @return [Array<String>] every `extensions.code` present, in order.
45
+ def codes
46
+ extensions.filter_map { |ext| ext['code'] }
47
+ end
48
+
49
+ # ★ The fastest way to tell a permission problem from a request problem.
50
+ # @return [Array] every `extensions.details` present. Shape is the server's —
51
+ # typically a Hash keyed by the failing policy, but sometimes an Array.
52
+ def details
53
+ extensions.filter_map { |ext| ext['details'] }
54
+ end
55
+
56
+ # @return [Array<String>] flattened `extensions.fullMessages`.
57
+ def full_messages
58
+ extensions.flat_map { |ext| Array(ext['fullMessages']) }
59
+ end
60
+
61
+ # @return [Boolean] whether ANY error was an authorization refusal. Use this to
62
+ # branch on "you cannot" vs "that request was wrong" — do not pattern-match
63
+ # the message text, which is prose and drifts.
64
+ def unauthorized?
65
+ codes.include?('unauthorized')
66
+ end
67
+
68
+ private
69
+
70
+ # The joined messages stay FIRST and unchanged, so existing callers matching on
71
+ # the message text keep working; the diagnosis is appended after them.
72
+ def build_message
73
+ base = errors.filter_map { |err| err['message'] }.join('; ')
74
+ base = 'GraphQL request failed' if base.empty?
75
+ [base, diagnosis].compact.join(' | ')
76
+ end
77
+
78
+ def diagnosis
79
+ parts = []
80
+ parts << "code: #{codes.uniq.join(', ')}" if codes.any?
81
+ parts << "details: #{details.inspect}" if details.any?
82
+ return if parts.empty?
83
+
84
+ parts.join(' | ')
85
+ end
86
+ end
6
87
 
7
88
  class HttpClient < Common::Client
8
89
  class << self
@@ -184,7 +265,9 @@ module Ecoportal
184
265
 
185
266
  parsed = response.body
186
267
  errors = parsed && parsed['errors']
187
- raise ResponseError, errors.map { |e| e['message'] }.join('; ') if errors&.any?
268
+ # Pass the raw entries through: `extensions` (and therefore the failing
269
+ # policy under `extensions.details`) is the whole point — see Q-27.
270
+ raise ResponseError.new(errors: errors) if errors&.any?
188
271
 
189
272
  parsed
190
273
  end
@@ -0,0 +1,97 @@
1
+ module Ecoportal::API::Common::GraphQL::Model::Diffable
2
+ # Diff strategy for models whose *array* attributes are leaf DATA, not nested
3
+ # `GraphQL::Model` children.
4
+ #
5
+ # Array attributes declared with `passarray` (tag lists, id lists, config lists)
6
+ # — and `embeds_many` id-collections written as a whole set (e.g. page `locations`)
7
+ # — are registered as *cascaded* attributes. The default `DiffService`:
8
+ # 1. strips cascaded keys from the flat classic diff
9
+ # (`doc_with_non_cascaded_attributes`), on the assumption that a later
10
+ # `diff_reduce` pass re-adds them by cascading into nested `GraphQL::Model`
11
+ # children; and
12
+ # 2. in `diff_reduce`, deletes the flat placeholder for every cascaded key and
13
+ # only re-adds it when the child object `is_a?(GraphQL::Model)`.
14
+ # A `passarray` is wrapped as a v2 `ArrayModel` and a whole-set `embeds_many` as a
15
+ # `CollectionModel` — neither is a `GraphQL::Model`, so the re-add never happens and
16
+ # the array change becomes **invisible to `as_update`** (scalar `passthrough` fields
17
+ # are unaffected). This is the systemic "array/leaf fields don't diff" root cause
18
+ # (see the repo's internal docs §3.1).
19
+ #
20
+ # This service fixes it for leaf models by:
21
+ # 1. keeping cascaded keys in the flat classic diff (`doc_with_non_cascaded_attributes`
22
+ # is a no-op), and
23
+ # 2. overriding `diff_reduce` so the flat-diff placeholder for a NON-model leaf
24
+ # child is preserved (only real `GraphQL::Model` children are cascade-diffed;
25
+ # `root!` children are still dropped as independent entities), and
26
+ # 3. treating a leaf array as an OPAQUE value: a change replaces the whole array
27
+ # (a full-set replace), rather than the id-keyed nested update/delete/create
28
+ # patch-ops the default array diff would emit. This matches how the write inputs
29
+ # consume these arrays (`PageInput.otherTags`, `PageInput.locations = [{ id: }]`,
30
+ # register `filterTags`, PresetView `fieldConfigurations`).
31
+ #
32
+ # A model can either behave as a full leaf (call `dirty?`/`as_update` with
33
+ # `flat: true`, as `Base::Page::DataField` does — it has no nested `GraphQL::Model`
34
+ # children at all) or as a mixed model with both leaf arrays and real nested model
35
+ # children (page / register / preset view — the default cascaded `as_update` still
36
+ # recurses into the model children while the leaf arrays now diff inline).
37
+ class LeafDiffService < DiffService
38
+ private
39
+
40
+ # Keep all doc keys: leaf-field arrays are data, not nested models, so the flat
41
+ # classic diff must see them (the default strips every cascaded key).
42
+ def doc_with_non_cascaded_attributes(json)
43
+ json
44
+ end
45
+
46
+ # Treat array values as opaque leaves: emit the whole (current) array on any
47
+ # change, instead of the default id-keyed nested-array patch-ops. Non-array
48
+ # values fall through to the standard diff.
49
+ def change_diff(curr, prev, ignore: [])
50
+ return super unless curr.is_a?(Array) || prev.is_a?(Array)
51
+ return NO_CHANGES if same_array?(curr, prev)
52
+
53
+ keys_to_sym_deep(curr)
54
+ end
55
+
56
+ # Full-set equality (order-insensitive), mirroring `patch_data_flat_array`.
57
+ def same_array?(curr, prev)
58
+ curr ||= []
59
+ prev ||= []
60
+ curr.length == prev.length && (curr & prev).length == prev.length
61
+ end
62
+
63
+ # Preserve the flat-diff placeholder for non-model leaf children; only
64
+ # cascade-diff real `GraphQL::Model` children, and still drop `root!` children.
65
+ def diff_reduce(init, ignore: [])
66
+ subject.cascaded_reduce(init, recurs: false) do |result, obj, _key, key_path, _trace|
67
+ next result if key_path.empty?
68
+ next result if obj == subject
69
+
70
+ sym_path = key_path.map(&:to_sym)
71
+
72
+ unless cascadeable_model?(obj)
73
+ # Leaf array (ArrayModel / CollectionModel) → keep the flat-diff value.
74
+ # A root! child is not mutation payload → drop its placeholder.
75
+ dig_delete!(result, sym_path) if root?(obj) && result && dig_path?(result, sym_path)
76
+ next result
77
+ end
78
+
79
+ # Remove the flat-diff placeholder before the child computes its own diff.
80
+ dig_delete!(result, sym_path) if result && dig_path?(result, sym_path)
81
+
82
+ value = obj.as_update(ignore: ignore)
83
+ next result if value.nil?
84
+
85
+ result ||= {}
86
+ result[:id] ||= get_id(keys_to_sym_deep(curr_doc(flat: true) || {}), exception: false)
87
+ dig_set!(result, sym_path, value)
88
+ result
89
+ end
90
+ end
91
+
92
+ # A child worth cascading into: a non-root GraphQL::Model.
93
+ def cascadeable_model?(obj)
94
+ obj.is_a?(Ecoportal::API::Common::GraphQL::Model) && !root?(obj)
95
+ end
96
+ end
97
+ end
@@ -7,6 +7,7 @@ module Ecoportal
7
7
  require_relative 'diffable/hash_diff_nesting'
8
8
  require_relative 'diffable/classic_diff_service'
9
9
  require_relative 'diffable/diff_service'
10
+ require_relative 'diffable/leaf_diff_service'
10
11
 
11
12
  DIFF_CLASS = DiffService
12
13
 
@@ -29,7 +29,7 @@ module Ecoportal
29
29
  embeds_one :createdAt, klass: Base::DateTime
30
30
  embeds_one :updatedAt, klass: Base::DateTime
31
31
  embeds_one :completedAt, klass: Base::DateTime
32
- embeds_one :openededAt, klass: Base::DateTime
32
+ embeds_one :openedAt, klass: Base::DateTime
33
33
  embeds_one :closedAt, klass: Base::DateTime
34
34
  embeds_one :dueOrClosedDate, klass: Base::DateTime
35
35
 
@@ -0,0 +1,23 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # A scheduled/recurring occurrence of an action (calendar-style event view).
6
+ # Read-only. `occursAt` is an ISO8601 timestamp scalar (a plain string),
7
+ # not the `{ dateTime:, timeZone: }` DateTime object used elsewhere.
8
+ #
9
+ # status is exposed as a plain string ('open' | 'closed').
10
+ class ActionEvent < Logic::BaseModel
11
+ read_only!
12
+
13
+ passkey :id
14
+ passthrough :uid
15
+ passthrough :title, :subtitle, :description
16
+ passthrough :status, :relativeStatus
17
+ passthrough :occursAt
18
+ passboolean :archived, :recurring, :standaloneAction
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # The page (and stages) an ActionEvent is attached to. Read-only.
6
+ class ActionEventPage < Logic::BaseModel
7
+ read_only!
8
+
9
+ passkey :id
10
+ passthrough :name, :fieldId
11
+ passarray :stageIds
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # A single entry of a page's activity/history feed, returned by
6
+ # `page.activities` (ActivityInterfaceConnection). Read-only.
7
+ #
8
+ # `ActivityInterface` is an INTERFACE with ~61 concrete implementations
9
+ # (ActivityPageChangelog, ActivityComment, ActivityStageAssigned,
10
+ # ActivityCreated, ActivityEmailOut, ActivityFallback, ...). Rather than
11
+ # hand-model all of them, this captures the fields COMMON to the interface
12
+ # and passes the concrete `__typename` through so callers can branch on it.
13
+ #
14
+ # Enum-valued fields are exposed as plain strings:
15
+ # - priority: PriorityEnum
16
+ class Activity < Logic::BaseModel
17
+ read_only!
18
+
19
+ passkey :id
20
+ # Concrete type name (e.g. 'ActivityPageChangelog', 'ActivityComment', 'ActivityFallback').
21
+ passthrough :__typename
22
+ passthrough :priority
23
+ passthrough :stageId
24
+ passthrough :timeZone
25
+ # Origin of the activity — sourceId/sourceName are scalars; the polymorphic
26
+ # `source: SourceUnion` (BasicPage|PhasedPage) subtree is intentionally omitted.
27
+ passthrough :sourceId, :sourceName
28
+ passthrough :userId, :userEmail, :userName
29
+ passarray :categories, :subkey
30
+ passboolean :system, :fromPublicTemplate
31
+
32
+ embeds_one :createdAt, klass: Base::DateTime
33
+ embeds_one :updatedAt, klass: Base::DateTime
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # A single "System Access Log" entry, returned by
6
+ # `currentOrganization.activityLogs` (ActivityLogUnionConnection). Read-only.
7
+ #
8
+ # `ActivityLogUnion` is a UNION over 9 members:
9
+ # DashboardAccess, DashboardCreate, DashboardDelete, DashboardUpdate,
10
+ # DeniedDashboardAccess, DeniedPageAccess, FileDownload, PageAccess,
11
+ # ScimIntegrationAccess.
12
+ # This models the field set COMMON to ALL members and passes the concrete
13
+ # `__typename` through. The Dashboard* members additionally expose
14
+ # `parentResource*` + `resource` (see Model::ActivityLog); page/file/scim
15
+ # members do not, so those live on the Model subclass as nullable embeds.
16
+ #
17
+ # Enum-valued fields are exposed as plain strings:
18
+ # - resourceType: ResourceTypesEnum
19
+ class ActivityLog < Logic::BaseModel
20
+ read_only!
21
+
22
+ passkey :id
23
+ # Concrete member type (e.g. 'DashboardAccess', 'PageAccess', 'FileDownload').
24
+ passthrough :__typename
25
+ passthrough :activityType
26
+ passthrough :source
27
+ passthrough :userType
28
+ passthrough :accountStatus
29
+ passthrough :becomingEmail
30
+ # content: Hash! — free-form activity payload; passed through as a Hash.
31
+ passthrough :content
32
+ passthrough :resourceId, :resourceName, :resourceType
33
+ embeds_one :createdAt, klass: Base::DateTime
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,14 +2,19 @@ module Ecoportal
2
2
  module API
3
3
  class GraphQL
4
4
  module Base
5
- # Represents one generation version of an AiSummary field.
6
- # Returned by generateAiSummary — tracks the prompt, output, token usage, and status.
5
+ # One generation version of an AI page summary — the `item` of `generatePageSummary`.
6
+ #
7
+ # ★ Corrected 2026-07-30 against the live schema. The previous declarations (`prompt`,
8
+ # `inputTokens`, `outputTokens`, `totalTokens`, `resourceType`, `ratings`) do not exist on
9
+ # `AiSummaryVersion` and always read nil. The real fields are: aiSummary, alreadyRated,
10
+ # createdAt, dataFieldLogs, generationErrors, id, pagePatchVer, status.
11
+ #
12
+ # `dataFieldLogs` / `generationErrors` / `createdAt` are left unmodelled until their own
13
+ # shapes are verified — select and model them deliberately rather than by assumption.
7
14
  class AiSummaryVersion < Logic::BaseModel
8
- passkey :id
9
- passthrough :aiSummary, :status, :prompt
10
- passthrough :inputTokens, :outputTokens, :totalTokens
11
- passthrough :resourceType # "field" | "stage" | "page"
12
- passarray :ratings # [[user_id, score], ...]
15
+ passkey :id
16
+ passthrough :aiSummary, :status, :pagePatchVer
17
+ passboolean :alreadyRated
13
18
  end
14
19
  end
15
20
  end
@@ -0,0 +1,24 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # System access log entry (SCIM / security auditing), returned by
6
+ # `Organization.authLogs`. Read-only.
7
+ #
8
+ # Enum-valued fields are exposed as plain strings:
9
+ # - event: 'LOGIN' | 'LOGOUT' | 'PASSWORD_CHANGE' | 'PASSWORD_RESET_REQUEST'
10
+ # - loginMethod: 'PASSWORD' | 'SAML' | 'MAGIC_LINK' | 'REDIRECT' | 'OAUTH'
11
+ # - source: 'WEB' | 'MOBILE'
12
+ class AuthLog < Logic::BaseModel
13
+ read_only!
14
+
15
+ passkey :id
16
+ passthrough :event, :source
17
+ passthrough :loginMethod
18
+ passthrough :host, :ipAddress, :referrer, :userAgent
19
+ embeds_one :createdAt, klass: Base::DateTime
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # AuthorizationResult — the result of a permission / ability check (a `canXXX` field).
6
+ # value: Boolean! — whether the action is allowed
7
+ # message: String — human-readable reason when denied
8
+ # reasons: FailureReasons — structured reasons (not modelled here; omitted from the
9
+ # :AuthorizationResult fragment to avoid pulling that subtree).
10
+ # Reusable node type behind the schema's many `canXXX: AuthorizationResult` fields.
11
+ class AuthorizationResult < Logic::BaseModel
12
+ read_only!
13
+
14
+ passboolean :value
15
+ passthrough :message
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,7 +3,13 @@ module Ecoportal
3
3
  class GraphQL
4
4
  module Base
5
5
  class ContractorEntity
6
+ # Convenience mutators over the read-model `associatedPeopleIds` / `leadContractorIds`
7
+ # arrays. They push/delete plain ids so that `Input::ContractorEntity::Update.from_model`
8
+ # can later emit the IdDiff (additions/removals) against `original_doc`.
6
9
  module MemberChanges
10
+ # Associates one or more people. When `lead: true`, also marks them as leads.
11
+ # @param values [Array<String, Hash, Person, Enumerable>] ids (or things resolvable to ids).
12
+ # @param lead [Boolean] whether the associated people are also lead contractors.
7
13
  def associate(*values, lead: false)
8
14
  ids = to_person_ids(*values)
9
15
 
@@ -15,7 +21,9 @@ module Ecoportal
15
21
  end
16
22
  end
17
23
 
18
- def dissassociate(*values)
24
+ # Disassociates one or more people (also removing them as leads).
25
+ # @param values [Array<String, Hash, Person, Enumerable>] ids (or resolvable to ids).
26
+ def disassociate(*values)
19
27
  ids = to_person_ids(*values)
20
28
 
21
29
  associatedPeopleIds.tap do |associated|
@@ -23,7 +31,10 @@ module Ecoportal
23
31
  associated.delete!(*ids)
24
32
  end
25
33
  end
34
+ # @deprecated Backwards-compatible alias for the original misspelling.
35
+ alias_method :dissassociate, :disassociate
26
36
 
37
+ # Marks one or more people as lead contractors (associating them if not already).
27
38
  def lead!(*values)
28
39
  ids = to_person_ids(*values)
29
40
 
@@ -33,6 +44,7 @@ module Ecoportal
33
44
  end
34
45
  end
35
46
 
47
+ # Removes one or more people from the lead contractors list.
36
48
  def unlead!(*values)
37
49
  ids = to_person_ids(*values)
38
50
 
@@ -44,7 +56,7 @@ module Ecoportal
44
56
  private
45
57
 
46
58
  def to_person_ids(*values)
47
- to_person_id(values.flatten)
59
+ Array(to_person_id(values.flatten)).flatten.compact
48
60
  end
49
61
 
50
62
  def to_person_id(value)
@@ -54,9 +66,9 @@ module Ecoportal
54
66
  when Ecoportal::API::V1::Person
55
67
  value.id
56
68
  when Hash
57
- to_person_id(['id'])
58
- when Enumarable
59
- value.map {|val| to_person_id(val)}.compact
69
+ value['id'] || value[:id]
70
+ when Enumerable
71
+ value.map { |val| to_person_id(val) }.compact
60
72
  end
61
73
  end
62
74
  end
@@ -2,11 +2,19 @@ module Ecoportal
2
2
  module API
3
3
  class GraphQL
4
4
  module Base
5
+ # Verified against the live schema 2026-07-30: activeFileAttachment, activeName,
6
+ # activeVersion, archive, canDownload, canShow, draft, fileAttachments, fileSize, id,
7
+ # locations, name, otherTags, tags, token.
8
+ #
9
+ # ★ `activeVersionId` was declared here and does NOT exist on the type (it is
10
+ # `activeVersion`) — it always read nil. `otherTags` was missing, which matters because
11
+ # `UploadInput.otherTags` is the only way to tag a container.
5
12
  class FileContainer < Logic::BaseModel
6
- passkey :id
7
- passthrough :name, :activeName
8
- passthrough :fileSize, :token, :activeVersionId
9
- passarray :tags
13
+ passkey :id
14
+ passthrough :name, :activeName, :activeVersion
15
+ passthrough :fileSize, :token
16
+ passboolean :draft
17
+ passarray :tags, :otherTags
10
18
  end
11
19
  end
12
20
  end
@@ -8,6 +8,8 @@ module Ecoportal
8
8
  passkey :id
9
9
  passthrough :name
10
10
  passboolean :active
11
+ # Whether this classification was AI-generated (2026-07 schema).
12
+ passboolean :aiGenerated, read_only: true
11
13
  end
12
14
  end
13
15
  end
@@ -11,6 +11,9 @@ module Ecoportal
11
11
  passkey :id
12
12
  passthrough :name, :weight
13
13
  passboolean :archived, :published, :visitorManagementEnabled
14
+ # AI-generated structure support (2026-07 schema).
15
+ passthrough :aiPrompt, read_only: true
16
+ passboolean :generating, read_only: true
14
17
  embeds_one :updatedAt, klass: Base::DateTime
15
18
 
16
19
  embeds_many :drafts, klass: :draft_class, order_key: :createdAt
@@ -0,0 +1,25 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # Lightweight dashboard descriptor returned by the `dashboardSearch` connection.
6
+ # Read-only. For the full dashboard (components, filters, permissions) the
7
+ # `Dashboard` type is not yet modelled — see docs/worklog.md.
8
+ #
9
+ # Enum-valued fields are exposed as plain strings:
10
+ # - permissionType: 'GLOBAL' | 'RESTRICTED' | 'PRIVATE'
11
+ # - parentResourceType: DashboardParentResource (REGISTER, REPORTING, TASKS, ...)
12
+ class MinimalDashboard < Logic::BaseModel
13
+ read_only!
14
+
15
+ passkey :id
16
+ passthrough :name
17
+ passthrough :parentResource, :parentResourceId, :parentResourceType
18
+ passthrough :permissionType
19
+ passthrough :weight
20
+ passboolean :global
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -8,6 +8,8 @@ module Ecoportal
8
8
  passkey :id
9
9
  passthrough :name, :companyName
10
10
  passthrough :logoUrl, read_only: true
11
+ # Whether contractor admins may add other contractor admins (2026-07 schema).
12
+ passboolean :allowContractorAdminsToAddAdmins, read_only: true
11
13
  end
12
14
  end
13
15
  end
@@ -3,10 +3,13 @@ module Ecoportal::API::GraphQL::Base::Page
3
3
  # Contractor entity selector field.
4
4
  # Stores references to ContractorEntity records.
5
5
  class ContractorEntities < DataField
6
- passarray :contractors # each: { id:, name: }
6
+ passarray :contractors # staged writes; the READ response uses `contractorEntities`
7
7
 
8
+ # Current contractor-entity ids. The GraphQL response carries them under
9
+ # `contractorEntities` (CommonPageUnion's contractorEntitiesField), NOT `contractors`
10
+ # — so read from there. After a contractor_entity_ids= write, prefer the staged value.
8
11
  def contractor_entity_ids
9
- Array(contractors).map { |c| c.is_a?(Hash) ? c['id'] : c }.compact
12
+ contractor_ids_from(doc)
10
13
  end
11
14
 
12
15
  def contractor_entity_ids=(ids)
@@ -18,11 +21,31 @@ module Ecoportal::API::GraphQL::Base::Page
18
21
  self.contractor_entity_ids = ids
19
22
  end
20
23
 
24
+ # Dirty only when the effective id SET actually changed — same phantom-dirty class as
25
+ # CrossReference (READ one key, WRITE another): the default leaf diff flags the field
26
+ # the moment the `contractors` key appears, even for a no-op restatement or a clear on
27
+ # an already-empty field.
28
+ def dirty?
29
+ contractor_ids_from(doc).sort != contractor_ids_from(original_doc).sort
30
+ end
31
+
21
32
  def as_input
22
33
  return nil unless dirty?
23
34
 
24
35
  { contractorEntities: { id: id, contractorEntityIds: contractor_entity_ids } }
25
36
  end
37
+
38
+ private
39
+
40
+ # Prefer the staged `contractors` (write shape), fall back to `contractorEntities`
41
+ # (read shape). Shape-agnostic for both `doc` and `original_doc`, so the comparison
42
+ # stays correct after `consolidate!` copies the staged shape into the baseline.
43
+ def contractor_ids_from(source_doc)
44
+ return [] unless source_doc
45
+
46
+ src = source_doc.key?('contractors') ? source_doc['contractors'] : source_doc['contractorEntities']
47
+ Array(src).map { |c| c.is_a?(Hash) ? c['id'] : c }.compact
48
+ end
26
49
  end
27
50
  end
28
51
  end