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
@@ -1,24 +1,63 @@
1
1
  module Ecoportal::API::GraphQL::Base::Page
2
2
  class DataField
3
3
  # File attachment field.
4
- # Writing requires a prior file upload to the REST upload endpoint
5
- # to obtain fileContainerIds — see the file-upload TODO in projects/TODO.md.
6
- # The file_container_ids= setter can be used once IDs are available.
4
+ # READ: the fragment selects `items { id fileContainer { id ... } }` — the container id
5
+ # lives at items[].fileContainer.id, NOT in a `fileContainers` array (live-verified;
6
+ # the previous reader keyed on a doc key the API never returns).
7
+ # WRITE: live `FileInput` accepts `items: [ItemInput]` ({id, fileContainerId, position}),
8
+ # NOT `fileContainerIds`. The backend applies `items` as FULL-REPLACE — any existing item
9
+ # whose id is not echoed back is destroyed — so `as_input` sends kept items as `{id:}`
10
+ # and new containers as `{fileContainerId:}`.
11
+ # `items` is deliberately a plain doc reader, not `passarray`: materialising an ArrayModel
12
+ # writes `items: []` into the doc of a field read without $content, and a phantom-dirty
13
+ # empty `items` here doesn't no-op — it DELETES every attachment on the next update.
14
+ # Container ids come from a prior upload — see FileUpload::Client#upload_all.
7
15
  class FileField < DataField
8
- passarray :fileContainers # each: { id:, fileName:, ... }
16
+ # each: { 'id' =>, 'fileContainer' => { 'id' =>, 'activeFileAttachment' => {...} } }
17
+ def items
18
+ Array(doc['items'])
19
+ end
9
20
 
10
21
  def file_container_ids
11
- Array(fileContainers).map { |c| c.is_a?(Hash) ? c['id'] : c }.compact
22
+ items.map do |item|
23
+ item.is_a?(Hash) ? item.dig('fileContainer', 'id') : item
24
+ end.compact
12
25
  end
13
26
 
27
+ # Full-replace: containers already on the field keep their item identity;
28
+ # ids not present in `ids` are removed server-side by omission.
14
29
  def file_container_ids=(ids)
15
- doc['fileContainers'] = Array(ids).map { |id_val| { 'id' => id_val } }
30
+ kept = {}
31
+ items.each do |item|
32
+ next unless item.is_a?(Hash) && (fc_id = item.dig('fileContainer', 'id'))
33
+
34
+ kept[fc_id] ||= item['id']
35
+ end
36
+ doc['items'] = Array(ids).map do |fc_id|
37
+ new_item = { 'fileContainer' => { 'id' => fc_id } }
38
+ new_item['id'] = kept[fc_id] if kept[fc_id]
39
+ new_item
40
+ end
16
41
  end
17
42
 
18
43
  def as_input
19
44
  return nil unless dirty?
20
45
 
21
- { file: { id: id, fileContainerIds: file_container_ids } }
46
+ { file: { id: id, items: items_input } }
47
+ end
48
+
49
+ private
50
+
51
+ def items_input
52
+ items.map do |item|
53
+ next unless item.is_a?(Hash)
54
+
55
+ if (item_id = item['id'])
56
+ { id: item_id }
57
+ else
58
+ { fileContainerId: item.dig('fileContainer', 'id') }
59
+ end
60
+ end.compact
22
61
  end
23
62
  end
24
63
  end
@@ -1,8 +1,11 @@
1
1
  module Ecoportal::API::GraphQL::Base::Page
2
2
  class DataField
3
3
  # Image gallery field.
4
- # Writing requires a prior file upload to the REST upload endpoint
5
- # to obtain fileContainerIds — see the file-upload TODO in projects/TODO.md.
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`.
6
9
  class ImageGallery < DataField
7
10
  passarray :fileContainers # each: { id:, fileName:, url:, ... }
8
11
 
@@ -4,20 +4,16 @@ module Ecoportal::API::GraphQL::Base::Page
4
4
  passarray :peopleIds
5
5
 
6
6
  def people_ids
7
- ids = Array(peopleIds)
8
- return ids unless ids.empty?
9
-
10
- # Fallback: the read fragment populates `peopleIds`, but if a response ever returns it
11
- # null/empty while the expanded `people { id }` nodes ARE present (a partial / non-
12
- # $content read), seed from the nodes. The backend write replaces peopleIds wholesale,
13
- # so a `people_ids << x` append must start from the existing set or it would WIPE the
14
- # field. Mirrors CrossReference's references.nodes fallback. Read key == write key
15
- # (peopleIds), so this only widens the read; it never creates phantom-dirty.
16
- Array(doc['people']).map { |p| p.is_a?(Hash) ? p['id'] : p }.compact
7
+ people_ids_from(doc)
17
8
  end
18
9
 
19
10
  def people_ids=(ids)
20
- doc['peopleIds'] = Array(ids).compact
11
+ # An explicit write makes `peopleIds` authoritative for THIS instance: without the
12
+ # flag, a deliberate clear (`people_ids = []`) on a `peopleIds: null` baseline would
13
+ # fall back to the `people` nodes on the doc side too, compare equal, and be silently
14
+ # dropped. The flag confines the fallback to un-written docs.
15
+ @people_ids_written = true
16
+ doc['peopleIds'] = Array(ids).compact
21
17
  end
22
18
 
23
19
  # v2 compat: scripts may set via people_ids= or direct assignment
@@ -25,10 +21,52 @@ module Ecoportal::API::GraphQL::Base::Page
25
21
  self.people_ids = ids
26
22
  end
27
23
 
24
+ # Dirty only when the effective people-id SET actually changed.
25
+ #
26
+ # The read response can carry `peopleIds: null` while still populating the expanded
27
+ # `people { id }` (and `cachedPeople`) nodes (a partial / non-$content read). The
28
+ # default key-level leaf diff baselines on `original_doc['peopleIds']` (null), so
29
+ # writing the SAME id set as already attached looks like a brand-new key and flags
30
+ # a change — a phantom-dirty update that sends `peopleIds` for a no-op. Compare id
31
+ # SETS (falling back to `people[].id` on both sides) instead. Mirrors CrossReference's
32
+ # `references.nodes` fallback / `dirty?` override for the identical disease.
33
+ def dirty?
34
+ effective_people_ids.sort != people_ids_from(original_doc).sort
35
+ end
36
+
28
37
  def as_input
29
38
  return nil unless dirty?
30
39
 
31
- { people: { id: id, peopleIds: Array(peopleIds) } }
40
+ { people: { id: id, peopleIds: effective_people_ids } }
41
+ end
42
+
43
+ private
44
+
45
+ # The id set the client currently means: after an explicit `people_ids=` write the raw
46
+ # `peopleIds` doc key is authoritative (so `[]` means CLEAR, no fallback); before any
47
+ # write it is the shape-agnostic read (fallback to `people[].id` nodes). Keeping
48
+ # `as_input` on the same source as `dirty?` also prevents an explicit clear from
49
+ # re-serialising fallback-seeded node ids.
50
+ def effective_people_ids
51
+ return Array(doc['peopleIds']).compact if @people_ids_written
52
+
53
+ people_ids_from(doc)
54
+ end
55
+
56
+ # Extract the people id set from a doc snapshot. `original_doc` is the server baseline
57
+ # (the diff prev_doc — see Diffable::ClassicDiffService), `doc` carries client changes.
58
+ # Prefer `peopleIds` when it is present and non-empty (the write shape, and the normal
59
+ # read shape); fall back to the expanded `people[].id` nodes (the read-only lookup
60
+ # shape) when `peopleIds` is absent/null/empty — so the comparison is shape-agnostic on
61
+ # both sides and stays correct after `consolidate!` copies `doc` into `original_doc`.
62
+ # `people` nodes are a lookup only, never written.
63
+ def people_ids_from(source_doc)
64
+ return [] unless source_doc
65
+
66
+ ids = Array(source_doc['peopleIds']).compact
67
+ return ids unless ids.empty?
68
+
69
+ Array(source_doc['people']).map { |p| p.is_a?(Hash) ? p['id'] : p }.compact
32
70
  end
33
71
  end
34
72
  end
@@ -10,24 +10,12 @@ module Ecoportal
10
10
  #
11
11
  # A DataField's array attributes (`options`, `peopleIds`, `contractors`,
12
12
  # `pages`, `fileContainers`, `items`, ...) are declared with `passarray`,
13
- # which registers them as *cascaded* attributes. The default `DiffService`
14
- # strips cascaded keys from the flat classic diff (`doc_with_non_cascaded_attributes`)
15
- # on the assumption that a later `diff_reduce` pass re-adds them by cascading
16
- # into nested `GraphQL::Model` children. DataField arrays are plain hashes /
17
- # strings, not `GraphQL::Model` objects, so that re-add never happens and the
18
- # array change becomes invisible to the diff (scalar `passthrough` fields are
19
- # unaffected). For a leaf model we want every doc key diffed inline, so this
20
- # service keeps cascaded keys in place.
21
- class LeafDiffService < Ecoportal::API::Common::GraphQL::Model::Diffable::DiffService
22
- private
23
-
24
- # Keep all doc keys: leaf-field arrays are data, not nested models.
25
- def doc_with_non_cascaded_attributes(json)
26
- json
27
- end
28
- end
29
-
30
- DIFF_CLASS = LeafDiffService
13
+ # which registers them as *cascaded* attributes invisible to the default
14
+ # `DiffService` (see `Diffable::LeafDiffService` for the full explanation).
15
+ # A DataField is a pure leaf model (no nested `GraphQL::Model` children), so
16
+ # it diffs `flat: true` — the promoted shared `LeafDiffService` keeps the
17
+ # array doc keys inline.
18
+ DIFF_CLASS = Ecoportal::API::Common::GraphQL::Model::Diffable::LeafDiffService
31
19
  # Map GraphQL __typename values to subclass names.
32
20
  TYPE_MAP = {
33
21
  'PlainText' => 'PlainText',
@@ -55,6 +43,12 @@ module Ecoportal
55
43
 
56
44
  passkey :id
57
45
  passthrough :label, :deindex, :genomeSignature
46
+ # `read_only: true` is load-bearing, not documentation: `description` is readable on
47
+ # `DataFieldsInterface` but ABSENT from `DataFieldInput` and every per-type member of
48
+ # it, so a writable accessor would make the field dirty and push a key `updatePage`
49
+ # cannot accept. The only writer is the `editFieldConfiguration` workflow command
50
+ # (`Input::WorkflowCommand::EditFieldConfiguration`).
51
+ passthrough :description, read_only: true
58
52
  # __typename is stored under the 'type' key after mapping
59
53
  passthrough :type
60
54
 
@@ -10,6 +10,9 @@ module Ecoportal
10
10
  passkey :id
11
11
  passthrough :name, :ordering, :state
12
12
  passboolean :active, :started
13
+ # Per-stage tag set (Stage.tags in the schema). Fetched by the stageAttrs
14
+ # fragment; a component of #all_tags. Empty array when not queried.
15
+ passarray :tags
13
16
 
14
17
  class_resolver :callback_type_class, 'Ecoportal::API::GraphQL::Base::PagesWorkflow::CallbackType'
15
18
 
@@ -17,6 +20,54 @@ module Ecoportal
17
20
  # canonical field objects (filtered to this stage) rather than fresh dups.
18
21
  attr_writer :page_field_collection
19
22
 
23
+ # The owning page, resolved via the DoubleModel's NATIVE nesting reference rather
24
+ # than a manually-injected back-ref. A stage is embedded under the page as
25
+ # page -> stages (CollectionModel) -> stage, so `_parent` is the intermediate
26
+ # collection, NOT the page. `root` walks the whole `_parent` chain to the top of
27
+ # the doc model (the page), which is robust to that intermediate nesting.
28
+ #
29
+ # Returns the page for an embedded stage, or nil for a standalone stage whose root
30
+ # is itself (i.e. not a page — has no baseTags/otherTags/location_ids to fold in).
31
+ def page
32
+ top = root
33
+ top.equal?(self) ? nil : top
34
+ end
35
+
36
+ # location_ids redirect: the GraphQL Stage type has NO location field — locations
37
+ # are assigned at the PAGE level (page.locations { id }). So a stage's location ids
38
+ # are the page's location ids. Empty array for a standalone stage.
39
+ def location_ids
40
+ Array(page&.location_ids)
41
+ end
42
+
43
+ # The stage's fully-compiled tag set (client-side build of the backend-only
44
+ # "compiled_tags"), per the domain formula:
45
+ #
46
+ # Stage.all_tags = page.baseTags + page.location_ids + page.otherTags + stage.tags
47
+ #
48
+ # (page.location_ids — surfaced here via #location_ids — stands in for the per-stage
49
+ # "location_ids" of the formula: the GraphQL schema does NOT expose locations on the
50
+ # Stage type, they are assigned at the PAGE level.)
51
+ #
52
+ # Returns a de-duplicated Array of tag strings. For a standalone stage (no owning
53
+ # page) folds in only the stage's own tags.
54
+ def all_tags
55
+ (page_base_tags + location_ids + page_other_tags + Array(tags)).
56
+ compact.uniq
57
+ end
58
+
59
+ private
60
+
61
+ def page_base_tags
62
+ page ? Array(page.baseTags) : []
63
+ end
64
+
65
+ def page_other_tags
66
+ page ? Array(page.otherTags) : []
67
+ end
68
+
69
+ public
70
+
20
71
  def workflow_callbacks
21
72
  @workflow_callbacks ||= Array(doc['workflowCallbacks'] || []).map { |cb| callback_type_class.from_doc(cb) }
22
73
  end
@@ -0,0 +1,15 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # Permissions ? the org-level ability set (Organization.permissions).
6
+ # Every field is a `canXXX: AuthorizationResult!` permission check. Read-only.
7
+ # The concrete embeds_one declarations live on Model::Permissions (they reference
8
+ # Model::AuthorizationResult).
9
+ class Permissions < Logic::BaseModel
10
+ read_only!
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # A single audit entry in a person member's changelog history, returned by
6
+ # `PersonMember.changelogs` / `Organization.personMemberChangelogs`. Read-only.
7
+ #
8
+ # Enum-valued fields are exposed as plain strings:
9
+ # - operation / accountOperation: 'CREATE' | 'UPDATE' | 'DESTROY'
10
+ class PersonMemberChangelog < Logic::BaseModel
11
+ read_only!
12
+
13
+ passkey :id
14
+ passthrough :operation, :accountOperation
15
+ passthrough :personMemberId, :apiKeyPrefix
16
+ passboolean :changedByApi
17
+ passarray :humanReadableDifferences
18
+ embeds_one :createdAt, klass: Base::DateTime
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -5,11 +5,146 @@ module Ecoportal
5
5
  # A register preset view — saved column/sort/filter configuration for previewPages.
6
6
  # Scripts use the id as `presetViewId` in register.previewPages queries.
7
7
  class PresetView < Logic::BaseModel
8
+ # `fieldConfigurations` is a passarray (leaf) invisible to the default
9
+ # DiffService; LeafDiffService detects it as a full-set replace, which is what
10
+ # makes a columns-only change reach `#as_input` at all (before it, the array was
11
+ # dropped from the flat diff and no mutation was issued). The read-key/shape
12
+ # conversion that change then needs is handled below — the GAP-P1 that comment
13
+ # referred to as a separate pending converter is closed by it.
14
+ # See Diffable::LeafDiffService.
15
+ DIFF_CLASS = Ecoportal::API::Common::GraphQL::Model::Diffable::LeafDiffService
16
+
17
+ # READ key vs WRITE key — and, on update, read SHAPE vs write SHAPE.
18
+ #
19
+ # The API RETURNS the collection as `fieldConfigurations`, a plain list (see the
20
+ # `passarray` below — that stays as-is, consumers read it). The mutations call it
21
+ # `fieldConfigs`, and the two mutations do NOT agree on its shape. Schema-verified
22
+ # against the live introspection dump (20260730):
23
+ #
24
+ # CreatePresetViewInput.fieldConfigs : [FieldConfigurationInput!]!
25
+ # UpdatePresetViewInput.fieldConfigs : FieldConfigurationOneToManyInput!
26
+ # { additions:, deletions:, updates: }
27
+ #
28
+ # Both are NON_NULL with no default value — i.e. REQUIRED on every call — while the
29
+ # `FieldConfigurationOneToManyInput` members each default to `[]`, so `{}` is a
30
+ # valid no-op value for an update that does not touch the columns.
31
+ #
32
+ # Emitting the read key `fieldConfigurations` is an unknown-argument error, and
33
+ # emitting the read LIST under `fieldConfigs` is a type error: either one rejects
34
+ # the WHOLE mutation, a `name`-only edit included.
35
+ #
36
+ # `#as_input` emits the UPDATE shape. `Logic::Mutation#as_input` hands a read model
37
+ # straight to `model.as_input` without telling it which mutation it feeds, and the
38
+ # only model-driven path is `register.preset_view.update`: a read model carries an
39
+ # `id`, which `CreatePresetViewInput` does not even accept. Create is built from a
40
+ # caller-supplied Hash (see Builder::Register::PresetView) whose `fieldConfigs` is
41
+ # already the plain array the schema wants — it needs no conversion.
42
+ READ_KEY = 'fieldConfigurations'.freeze
43
+
44
+ # The fields `FieldConfigurationInput` accepts (same dump). The read nodes also
45
+ # carry `__typename` — the read side is a union, see Fragment::FieldConfiguration —
46
+ # which the input type does NOT accept, so nodes are whitelisted through this list
47
+ # rather than passed along verbatim.
48
+ FIELD_CONFIG_INPUT_KEYS = %i[
49
+ id key name weight dataFieldLabel type showTime valueToShow mode displayUid
50
+ ].freeze
51
+
8
52
  passkey :id
9
53
  passthrough :name, :registerId
10
54
  passboolean :global
11
55
  passarray :fieldConfigurations
12
56
  passthrough :sorter # { key:, direction:, missing: }
57
+
58
+ class << self
59
+ # Turns a raw mutation-input diff into a valid `UpdatePresetViewInput`: drops the
60
+ # read key and sets `fieldConfigs` to the one-to-many delta.
61
+ #
62
+ # Idempotent — the read key is deleted and the delta is recomputed from
63
+ # `doc`/`original_doc`, so a second application yields the same Hash. That is what
64
+ # makes it safe for both seams (`#as_input` and `Input::PresetView::Update
65
+ # .from_model`) to call it on the same Hash.
66
+ #
67
+ # @param model [PresetView] the subject (read) model.
68
+ # @param input [Hash, nil] the mutation input hash so far (nil when nothing in the
69
+ # flat diff changed).
70
+ # @return [Hash, nil] +input+ with a schema-valid `fieldConfigs`, or nil when
71
+ # neither the flat diff nor the columns changed (no mutation to send).
72
+ def build_update_input(model, input)
73
+ return input unless model.respond_to?(:field_configs_delta)
74
+
75
+ delta = model.field_configs_delta
76
+ return input if input.nil? && delta.empty?
77
+
78
+ input ||= {}
79
+ input.delete(:fieldConfigurations)
80
+ input.delete(READ_KEY)
81
+ input[:id] ||= model.id if model.respond_to?(:id)
82
+ input[:fieldConfigs] = delta
83
+ input
84
+ end
85
+
86
+ # @param node [Hash] one `fieldConfigurations` read node.
87
+ # @return [Hash] the node reduced to the keys `FieldConfigurationInput` accepts,
88
+ # symbol-keyed. Keys the read selection did not ask for are simply absent.
89
+ def field_config_input(node)
90
+ FIELD_CONFIG_INPUT_KEYS.each_with_object({}) do |key, out|
91
+ str = key.to_s
92
+ out[key] = node[str] if node.key?(str)
93
+ out[key] = node[key] if node.key?(key)
94
+ end
95
+ end
96
+ end
97
+
98
+ # The `FieldConfigurationOneToManyInput` delta between the server baseline
99
+ # (`original_doc`) and the current `doc`, matched on the config `id`:
100
+ # `additions` = nodes with no known id, `deletions` = baseline ids that are gone,
101
+ # `updates` = known nodes whose (input-visible) content changed. Empty members are
102
+ # omitted, so an untouched collection yields `{}` — the valid no-op.
103
+ #
104
+ # Computed DIRECTLY from the docs rather than from the flat diff, for the same
105
+ # reason as {Logic::Input::IdDiffFields}: `passarray` leaves are dropped by the
106
+ # cascaded DiffService.
107
+ #
108
+ # @return [Hash] `{ additions:, deletions:, updates: }`, empty members omitted.
109
+ def field_configs_delta
110
+ current = field_config_nodes(doc)
111
+ original = field_config_nodes(original_doc)
112
+ known = original.filter_map {|node| node[:id]}
113
+
114
+ additions = current.reject {|node| node[:id] && known.include?(node[:id])}
115
+ updates = (current - additions) - original
116
+ deletions = known - current.filter_map {|node| node[:id]}
117
+
118
+ {}.tap do |delta|
119
+ delta[:additions] = additions unless additions.empty?
120
+ delta[:deletions] = deletions unless deletions.empty?
121
+ delta[:updates] = updates unless updates.empty?
122
+ end
123
+ end
124
+
125
+ # Mutation input for this preset view, shaped for `updatePresetView`.
126
+ # @see .build_update_input
127
+ def as_input(target_class: nil, clientMutationId: '')
128
+ input = self.class.build_update_input(self, super)
129
+ return input if input.nil? || target_class
130
+
131
+ # `super` merges it only when it built the Hash itself; a columns-only change
132
+ # synthesises the Hash here, so keep the wire payload identical either way.
133
+ input[:clientMutationId] = clientMutationId.to_s unless input.key?(:clientMutationId)
134
+ input
135
+ end
136
+
137
+ private
138
+
139
+ # @param source_doc [Hash, nil] `doc` or `original_doc`.
140
+ # @return [Array<Hash>] the read nodes, each reduced to input-accepted keys.
141
+ def field_config_nodes(source_doc)
142
+ return [] if source_doc.nil?
143
+
144
+ Array(source_doc[READ_KEY]).
145
+ grep(Hash).
146
+ map {|node| self.class.field_config_input(node)}
147
+ end
13
148
  end
14
149
  end
15
150
  end
@@ -3,6 +3,11 @@ module Ecoportal
3
3
  class GraphQL
4
4
  module Base
5
5
  class Register < Logic::BaseModel
6
+ # `filterTags` is a passarray (leaf) that the default DiffService cannot
7
+ # diff; LeafDiffService detects it as a full-set replace. See
8
+ # Diffable::LeafDiffService.
9
+ DIFF_CLASS = Ecoportal::API::Common::GraphQL::Model::Diffable::LeafDiffService
10
+
6
11
  passkey :id
7
12
  passthrough :name, :iconName, :moduleType, :registerStatus
8
13
  passthrough :descriptionText, :descriptionImageUrl
@@ -0,0 +1,118 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ module Template
6
+ # One entry of a TEMPLATE's change log — a single command the platform recorded
7
+ # against the template. Read-only.
8
+ #
9
+ # ── Where it comes from (verified in the Rails backend on 2026-08-19) ────────────
10
+ # `app/graphql/types/pages/template_type.rb` declares
11
+ # field :commands, Types::Pages::Interfaces::WorkflowCommandInterface.connection_type
12
+ # and resolves it as
13
+ # NewEp::Pages::Workflows::Commands::Base.where(page: object.page)
14
+ # .from_template_builder.order(_id: :desc)
15
+ #
16
+ # The narrow `Template` type itself is reachable as `template { ... }` on any page,
17
+ # because `app/graphql/types/pages/interfaces/base_page_interface.rb:27` declares
18
+ # field :template, Types::Pages::TemplateType, null: true
19
+ #
20
+ # ── ★ TWO ACCESS CAVEATS — both are silent, neither raises ─────────────────────
21
+ # 1. `commands` returns **nil**, not an empty connection, unless the caller satisfies
22
+ # `Pages::TemplatePolicy#update_information?` (`return unless allowed_to?(...)`).
23
+ # A read-only account therefore sees NO change log and gets no error saying so.
24
+ # Treat a nil connection as "not permitted", never as "nothing changed".
25
+ # 2. The scope is `.from_template_builder` — only `TEMPLATE_BUILDER`-sourced commands
26
+ # appear. `WORKFLOW_BUILDER` commands against the same page are EXCLUDED
27
+ # (`app/models/new_ep/pages/workflows/commands/base.rb`, `enum_field :feature_source`
28
+ # with values WORKFLOW_BUILDER / TEMPLATE_BUILDER). For the workflow-builder side of
29
+ # the same log see `Query::PagesWorkflowCommands`.
30
+ # Ordering is `_id: :desc` — newest first, server-side, not configurable.
31
+ #
32
+ # ── ★ WHY THIS LOG BEATS POLLING A WATERMARK ──────────────────────────────────
33
+ # `app/services/new_ep/pages/run_merged_forces.rb:17` writes with
34
+ # @page.timeless.skip_patch_incr.rewrite(only_if: { updated_at: @page.updated_at.utc })
35
+ # so a forces run bumps NEITHER `patchVer` NOR `updatedAt`. Any change tracker that
36
+ # polls a watermark is structurally blind to force-driven change; the command log is
37
+ # per-change and cannot be skipped that way.
38
+ # (OPEN: whether forces run against TEMPLATES specifically is NOT confirmed — do not
39
+ # assume either way.)
40
+ #
41
+ # ── Field set ─────────────────────────────────────────────────────────────────
42
+ # These are the fields of the INTERFACE `PagesWorkflowCommandInterface` only
43
+ # (`app/graphql/types/pages/interfaces/workflow_command_interface.rb`). The concrete
44
+ # implementing types add their own (`PagesWorkflowCommandChange.changes`,
45
+ # `PagesWorkflowCommandChangeMessage.changeMessages`,
46
+ # `PagesWorkflowCommandEsChange.changes`); `__typename` is passed through so a caller
47
+ # can branch, and those extras can be selected with an inline fragment or by reusing
48
+ # `Fragment :PagesWorkflowCommandFields`.
49
+ #
50
+ # Enum-valued fields are exposed as plain strings:
51
+ # - commandAction: WorkflowCommandActionEnum
52
+ class ChangeLogEntry < Logic::BaseModel
53
+ read_only!
54
+
55
+ passkey :id
56
+ # Concrete type name: 'PagesWorkflowCommandChange' | 'PagesWorkflowCommandChangeMessage'
57
+ # | 'PagesWorkflowCommandEsChange'.
58
+ passthrough :__typename
59
+ passthrough :commandAction
60
+ # Optimistic-concurrency token the command was recorded against (Int!).
61
+ passthrough :patchVer
62
+ # `owner` (the UserType embed) is added in Model::Template::ChangeLogEntry — Base
63
+ # loads before Model.
64
+ passthrough :ownerId
65
+ passthrough :stageId, :stageName
66
+ passthrough :taskId, :taskType
67
+ # What the command acted on. Resolved server-side from `targeted_resource` and
68
+ # nil for commands that target nothing.
69
+ passthrough :targetedResourceId, :targetedResourceName, :targetedResourceType
70
+
71
+ # `createdAt`/`updatedAt` are `DateTimeType` OBJECTS ({ dateTime, timeZone }), not
72
+ # scalars — hence an embed rather than `passdate`. Use `entry.createdAt.dateTime`.
73
+ embeds_one :createdAt, klass: Base::DateTime
74
+ embeds_one :updatedAt, klass: Base::DateTime
75
+
76
+ # ── What actually changed (only populated by the DETAIL selection) ──────────
77
+ # Present only when the node was selected with `Fragment :TemplateChangeLogEntryDetail`
78
+ # (see `Query::TemplateChangeLog.detail_block` / `api.template.change_log(detail: true)`).
79
+ # With the default light selection both readers are empty — empty means
80
+ # "not selected", NOT "nothing changed", exactly like the nil-connection caveat above.
81
+ #
82
+ # Deliberately raw hashes rather than a typed embed: the two `changes`-bearing types
83
+ # do not share a shape (`ChangeType.from/to` are Strings;
84
+ # `StoredEsFilterChangeType.from/to` are objects), so one typed model would have to
85
+ # lie about one of them. This mirrors `Base::PagesWorkflow::CommandChange#changes`,
86
+ # which does the same for the workflow-builder side of the same interface.
87
+ def changes
88
+ Array(doc['changes'])
89
+ end
90
+
91
+ passarray :changeMessages
92
+
93
+ # Which of the three concrete command types this entry is, as a symbol:
94
+ # :changes | :change_messages | :es_changes | nil (unknown/unselected `__typename`).
95
+ # Use it instead of string-matching `__typename` at call sites.
96
+ DETAIL_KINDS = {
97
+ 'PagesWorkflowCommandChange' => :changes,
98
+ 'PagesWorkflowCommandChangeMessage' => :change_messages,
99
+ 'PagesWorkflowCommandEsChange' => :es_changes
100
+ }.freeze
101
+
102
+ def detail_kind
103
+ DETAIL_KINDS[__typename]
104
+ end
105
+
106
+ # True when this entry carries field-level detail. False both when the light
107
+ # selection was used and when a command genuinely recorded no changes -- those two
108
+ # are indistinguishable from the response alone, which is why the caller should know
109
+ # which selection it asked for.
110
+ def detail?
111
+ !changes.empty? || !changeMessages.to_a.empty?
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -28,6 +28,8 @@ module Ecoportal
28
28
  passthrough :title, :registerId
29
29
  passboolean :hasInaccessibleVariables
30
30
  end
31
+
32
+ require_relative 'template/change_log_entry'
31
33
  end
32
34
  end
33
35
  end
@@ -20,13 +20,22 @@ require_relative 'base/page'
20
20
  require_relative 'base/resource'
21
21
  require_relative 'base/action_category'
22
22
  require_relative 'base/action'
23
+ require_relative 'base/action_event_page'
24
+ require_relative 'base/action_event'
23
25
  require_relative 'base/contractor_entity'
24
26
  require_relative 'base/preview_page'
25
27
  require_relative 'base/delta_result'
28
+ require_relative 'base/authorization_result'
29
+ require_relative 'base/permissions'
30
+ require_relative 'base/activity'
31
+ require_relative 'base/activity_log'
32
+ require_relative 'base/auth_log'
33
+ require_relative 'base/person_member_changelog'
26
34
  require_relative 'base/organization'
27
35
 
28
36
  require_relative 'base/register'
29
37
  require_relative 'base/preset_view'
38
+ require_relative 'base/minimal_dashboard'
30
39
 
31
40
  require_relative 'base/ai_summary_version'
32
41
 
@@ -21,6 +21,12 @@ module Ecoportal
21
21
  destroyMutation.query(**kargs, &block)
22
22
  end
23
23
 
24
+ # updateContractorManagerSettings — auto-approval settings + whether
25
+ # contractor admins may add other admins. Returns the updated Organization.
26
+ def update_manager_settings(**kargs, &block)
27
+ updateManagerSettingsMutation.query(**kargs, &block)
28
+ end
29
+
24
30
  private
25
31
 
26
32
  def createMutation
@@ -34,6 +40,10 @@ module Ecoportal
34
40
  def destroyMutation
35
41
  Mutation::ContractorEntity::Destroy.new(client)
36
42
  end
43
+
44
+ def updateManagerSettingsMutation
45
+ Mutation::ContractorEntity::UpdateManagerSettings.new(client)
46
+ end
37
47
  end
38
48
  end
39
49
  end