ecoportal-api-graphql 1.3.16 → 2.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -6
  3. data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +97 -0
  4. data/lib/ecoportal/api/common/graphql/model/diffable.rb +1 -0
  5. data/lib/ecoportal/api/graphql/base/action.rb +1 -1
  6. data/lib/ecoportal/api/graphql/base/action_event.rb +23 -0
  7. data/lib/ecoportal/api/graphql/base/action_event_page.rb +16 -0
  8. data/lib/ecoportal/api/graphql/base/activity.rb +38 -0
  9. data/lib/ecoportal/api/graphql/base/activity_log.rb +38 -0
  10. data/lib/ecoportal/api/graphql/base/ai_summary_version.rb +12 -7
  11. data/lib/ecoportal/api/graphql/base/auth_log.rb +24 -0
  12. data/lib/ecoportal/api/graphql/base/authorization_result.rb +20 -0
  13. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +17 -5
  14. data/lib/ecoportal/api/graphql/base/file_container.rb +12 -4
  15. data/lib/ecoportal/api/graphql/base/location_classification_type.rb +2 -0
  16. data/lib/ecoportal/api/graphql/base/location_structure.rb +3 -0
  17. data/lib/ecoportal/api/graphql/base/minimal_dashboard.rb +25 -0
  18. data/lib/ecoportal/api/graphql/base/organization.rb +2 -0
  19. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +26 -3
  20. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +47 -8
  21. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +5 -2
  22. data/lib/ecoportal/api/graphql/base/page/data_field/people.rb +50 -12
  23. data/lib/ecoportal/api/graphql/base/page/data_field.rb +12 -18
  24. data/lib/ecoportal/api/graphql/base/permissions.rb +15 -0
  25. data/lib/ecoportal/api/graphql/base/person_member_changelog.rb +23 -0
  26. data/lib/ecoportal/api/graphql/base/preset_view.rb +136 -1
  27. data/lib/ecoportal/api/graphql/base/register.rb +5 -0
  28. data/lib/ecoportal/api/graphql/base.rb +9 -0
  29. data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +10 -0
  30. data/lib/ecoportal/api/graphql/builder/page.rb +0 -16
  31. data/lib/ecoportal/api/graphql/builder/template.rb +18 -0
  32. data/lib/ecoportal/api/graphql/compat/pages.rb +7 -2
  33. data/lib/ecoportal/api/graphql/compat/response.rb +14 -0
  34. data/lib/ecoportal/api/graphql/concerns/threadable.rb +58 -0
  35. data/lib/ecoportal/api/graphql/concerns.rb +1 -0
  36. data/lib/ecoportal/api/graphql/connection/action.rb +1 -1
  37. data/lib/ecoportal/api/graphql/connection/action_category.rb +1 -1
  38. data/lib/ecoportal/api/graphql/connection/activity.rb +11 -0
  39. data/lib/ecoportal/api/graphql/connection/activity_log.rb +11 -0
  40. data/lib/ecoportal/api/graphql/connection/auth_log.rb +11 -0
  41. data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +1 -1
  42. data/lib/ecoportal/api/graphql/connection/minimal_dashboard.rb +11 -0
  43. data/lib/ecoportal/api/graphql/connection/page.rb +1 -1
  44. data/lib/ecoportal/api/graphql/connection/pages_workflow_command.rb +1 -1
  45. data/lib/ecoportal/api/graphql/connection/person_member.rb +1 -1
  46. data/lib/ecoportal/api/graphql/connection/person_member_changelog.rb +11 -0
  47. data/lib/ecoportal/api/graphql/connection/preview_page.rb +1 -1
  48. data/lib/ecoportal/api/graphql/connection.rb +5 -0
  49. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +21 -1
  50. data/lib/ecoportal/api/graphql/file_upload/client.rb +199 -140
  51. data/lib/ecoportal/api/graphql/fragment/activity.rb +84 -0
  52. data/lib/ecoportal/api/graphql/fragment/activity_log.rb +75 -0
  53. data/lib/ecoportal/api/graphql/fragment/auth_log.rb +29 -0
  54. data/lib/ecoportal/api/graphql/fragment/authorization_result.rb +14 -0
  55. data/lib/ecoportal/api/graphql/fragment/location_node.rb +1 -0
  56. data/lib/ecoportal/api/graphql/fragment/minimal_dashboard.rb +31 -0
  57. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +21 -0
  58. data/lib/ecoportal/api/graphql/fragment/permissions.rb +98 -0
  59. data/lib/ecoportal/api/graphql/fragment/person_member_changelog.rb +33 -0
  60. data/lib/ecoportal/api/graphql/fragment.rb +7 -0
  61. data/lib/ecoportal/api/graphql/input/action/update.rb +12 -0
  62. data/lib/ecoportal/api/graphql/input/contractor_entity/manager_settings.rb +20 -0
  63. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +6 -27
  64. data/lib/ecoportal/api/graphql/input/contractor_entity.rb +1 -0
  65. data/lib/ecoportal/api/graphql/input/date_range.rb +16 -0
  66. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +2 -2
  67. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +2 -2
  68. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +6 -2
  69. data/lib/ecoportal/api/graphql/input/preset_view/update.rb +16 -1
  70. data/lib/ecoportal/api/graphql/input/workflow_command/add_checklist_field_item.rb +19 -0
  71. data/lib/ecoportal/api/graphql/input/workflow_command/add_smart_fill_config.rb +23 -0
  72. data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +3 -2
  73. data/lib/ecoportal/api/graphql/input/workflow_command/add_viewable_field.rb +45 -0
  74. data/lib/ecoportal/api/graphql/input/workflow_command/archive_public_config.rb +19 -0
  75. data/lib/ecoportal/api/graphql/input/workflow_command/create_public_config.rb +19 -0
  76. data/lib/ecoportal/api/graphql/input/workflow_command/edit_checklist_field_item.rb +19 -0
  77. data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +12 -2
  78. data/lib/ecoportal/api/graphql/input/workflow_command/edit_public_config.rb +19 -0
  79. data/lib/ecoportal/api/graphql/input/workflow_command/edit_smart_fill_config.rb +21 -0
  80. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_advanced_settings.rb +19 -0
  81. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_basic_settings.rb +27 -0
  82. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +45 -0
  83. data/lib/ecoportal/api/graphql/input/workflow_command/{edit_page_creator_permissions.rb → edit_template_creator_permissions.rb} +3 -2
  84. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_descriptions.rb +19 -0
  85. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/action_list.rb +31 -0
  86. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +3 -1
  87. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +15 -2
  88. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/file.rb +21 -0
  89. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +3 -2
  90. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/people.rb +27 -3
  91. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +3 -2
  92. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +3 -2
  93. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +3 -2
  94. data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +3 -2
  95. data/lib/ecoportal/api/graphql/input/workflow_command/move_viewable_field.rb +33 -0
  96. data/lib/ecoportal/api/graphql/input/workflow_command/remove_checklist_field_item.rb +19 -0
  97. data/lib/ecoportal/api/graphql/input/workflow_command/remove_smart_fill_config.rb +19 -0
  98. data/lib/ecoportal/api/graphql/input/workflow_command/remove_viewable_field.rb +21 -0
  99. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +3 -2
  100. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +3 -2
  101. data/lib/ecoportal/api/graphql/input/workflow_command.rb +43 -5
  102. data/lib/ecoportal/api/graphql/input.rb +1 -0
  103. data/lib/ecoportal/api/graphql/interface/base_page.rb +36 -3
  104. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +2 -1
  105. data/lib/ecoportal/api/graphql/logic/connection.rb +17 -0
  106. data/lib/ecoportal/api/graphql/logic/input.rb +127 -0
  107. data/lib/ecoportal/api/graphql/model/action_event.rb +14 -0
  108. data/lib/ecoportal/api/graphql/model/action_event_page.rb +10 -0
  109. data/lib/ecoportal/api/graphql/model/activity.rb +14 -0
  110. data/lib/ecoportal/api/graphql/model/activity_log.rb +31 -0
  111. data/lib/ecoportal/api/graphql/model/auth_log.rb +11 -0
  112. data/lib/ecoportal/api/graphql/model/authorization_result.rb +12 -0
  113. data/lib/ecoportal/api/graphql/model/minimal_dashboard.rb +14 -0
  114. data/lib/ecoportal/api/graphql/model/organization.rb +27 -0
  115. data/lib/ecoportal/api/graphql/model/permissions.rb +179 -0
  116. data/lib/ecoportal/api/graphql/model/person_member.rb +7 -0
  117. data/lib/ecoportal/api/graphql/model/person_member_changelog.rb +15 -0
  118. data/lib/ecoportal/api/graphql/model.rb +9 -0
  119. data/lib/ecoportal/api/graphql/mutation/ai_summary/generate.rb +20 -10
  120. data/lib/ecoportal/api/graphql/mutation/ai_summary/submit_feedback.rb +25 -10
  121. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update_manager_settings.rb +37 -0
  122. data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +1 -0
  123. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +78 -0
  124. data/lib/ecoportal/api/graphql/mutation/file_container.rb +15 -0
  125. data/lib/ecoportal/api/graphql/mutation/template/update_related_page.rb +79 -0
  126. data/lib/ecoportal/api/graphql/mutation/template.rb +1 -0
  127. data/lib/ecoportal/api/graphql/mutation.rb +1 -1
  128. data/lib/ecoportal/api/graphql/payload/contractor_entity/manager_settings.rb +20 -0
  129. data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +1 -0
  130. data/lib/ecoportal/api/graphql/payload/template/update_related_page.rb +15 -0
  131. data/lib/ecoportal/api/graphql/payload/template.rb +1 -0
  132. data/lib/ecoportal/api/graphql/payload/upload.rb +12 -0
  133. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  134. data/lib/ecoportal/api/graphql/query/activity_logs.rb +78 -0
  135. data/lib/ecoportal/api/graphql/query/auth_logs.rb +51 -0
  136. data/lib/ecoportal/api/graphql/query/dashboard_search.rb +57 -0
  137. data/lib/ecoportal/api/graphql/query/location_structure.rb +2 -0
  138. data/lib/ecoportal/api/graphql/query/location_structures.rb +2 -0
  139. data/lib/ecoportal/api/graphql/query/page.rb +6 -1
  140. data/lib/ecoportal/api/graphql/query/page_activities.rb +56 -0
  141. data/lib/ecoportal/api/graphql/query/permissions.rb +35 -0
  142. data/lib/ecoportal/api/graphql/query/person_member_changelogs.rb +53 -0
  143. data/lib/ecoportal/api/graphql/query.rb +6 -0
  144. data/lib/ecoportal/api/graphql_version.rb +1 -1
  145. metadata +73 -12
  146. data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +0 -28
  147. data/lib/ecoportal/api/graphql/mutation/smart_fill/generate.rb +0 -36
  148. data/lib/ecoportal/api/graphql/mutation/smart_fill/submit_feedback.rb +0 -40
  149. data/lib/ecoportal/api/graphql/mutation/smart_fill.rb +0 -13
@@ -1,189 +1,248 @@
1
- require 'net/http'
1
+ require 'net/http'
2
2
  require 'uri'
3
3
  require 'json'
4
+ require 'base64'
5
+ require 'securerandom'
4
6
  require 'mime/types'
5
7
 
6
8
  module Ecoportal
7
9
  module API
8
10
  class GraphQL
9
11
  module FileUpload
10
- # Orchestrates the full file upload workflow:
12
+ # Uploads local files to the org file manager, natively.
11
13
  #
12
- # Step 1 Get S3 pre-signed credentials via GraphQL
13
- # Step 2 Upload file directly to S3 (multipart form POST)
14
- # Step 3 — Register the upload with EcoPortal (REST POST /v2/s3/files)
15
- # Step 4 — Poll until the FileContainer is created
14
+ # Rewritten 2026-07-30 from a CAPTURED web upload (see the `har-scrub` skill). The
15
+ # real flow is three steps, all GraphQL except the storage POST:
16
16
  #
17
- # Returns a fileContainerId string on success, suitable for use in
18
- # DataField::FileField#file_container_ids= or DataField::ImageGallery#file_container_ids=
17
+ # 1. `FileSignature` (GraphQL) -> presigned policy + credentials
18
+ # 2. multipart POST (S3) -> 204; fields, in order: key, AWSAccessKeyId, policy,
19
+ # signature, content-type,
20
+ # x-amz-server-side-encryption, file
21
+ # 3. `uploadFile` (GraphQL) -> the FileContainer; its `id` is what page mutations want
19
22
  #
20
- # Usage:
21
- # container_id = api.file_upload.upload('/path/to/report.pdf')
22
- # page.components.get_by_name('Attachment').file_container_ids = [container_id]
23
- # api.pages.update(page)
23
+ # The previous implementation was a port of `ecoportal-api-v2`'s REST S3 code and did
24
+ # `POST /api/v2/<org>/s3/files` + polling. **The platform does not use those endpoints
25
+ # for this flow** — no register-over-REST, no poll. That is also why no
26
+ # `X-ECOPORTAL-API-KEY` is needed here: every ecoPortal call is GraphQL, on the session
27
+ # token this client already holds.
24
28
  #
25
- # Step 1 (GraphQL) authenticates via the gem's email/pass OAuth bearer token.
26
- # Steps 3-4 (REST v2 `/s3/files`) require the gem to ALSO have been initialised
27
- # with `api_key:` — `Ecoportal::API::GraphQL.new(email:, pass:, api_key:)` since
28
- # those endpoints authenticate via `X-ECOPORTAL-API-KEY`, not the GraphQL bearer
29
- # token (mirrors `Ecoportal::API::Common::Content::Client#base_request` in
30
- # ecoportal-api-v2).
29
+ # Single file:
30
+ # id = api.file_upload.upload('/path/report.pdf')
31
+ # page.components.get_by_name('Report').file_container_ids = [id]
32
+ #
33
+ # Many files, concurrently, with per-file error isolation (nothing raises out):
34
+ # results = api.file_upload.upload_all(paths, threads: 4) do |r|
35
+ # puts r.success? ? "#{r.file} -> #{r.container_id}" : "#{r.file} FAILED: #{r.error}"
36
+ # end
37
+ # results.select(&:error?)
38
+ #
39
+ # Instrumentation / middleware — hooks fire per stage, per file:
40
+ # client = api.file_upload
41
+ # client.on(:signature) { |creds| logger.info "presigned #{creds.endpoint}" }
42
+ # client.on(:storage) { |key, response| logger.info "S3 #{response.code} #{key}" }
43
+ # client.on(:register) { |payload| logger.info "container #{payload.item&.id}" }
31
44
  class Client
32
- POLL_INTERVAL = 2 # seconds between poll attempts
33
- POLL_MAX_WAIT = 120 # seconds before giving up
45
+ include Ecoportal::API::GraphQL::Concerns::Threadable
46
+
47
+ class Error < StandardError; end
48
+ class MissingLocalFile < Error; end
49
+ class StorageUploadFailed < Error; end
50
+ class RegistrationFailed < Error; end
51
+
52
+ STAGES = %i[signature storage register].freeze
53
+ # Fallback only — the real value is a condition inside the returned policy.
54
+ DEFAULT_ENCRYPTION = 'AES256'.freeze
55
+ DEFAULT_MIME = 'application/octet-stream'.freeze
56
+ MAX_THREADS = 8
57
+
58
+ # One entry per input file. Mirrors `ecoportal-api-v2`'s
59
+ # `S3::Files::BatchUpload::FileResult` so batch code reads the same in both stacks.
60
+ Result = Struct.new(:file) do
61
+ attr_accessor :key, :payload, :error
62
+
63
+ def error?
64
+ !error.nil?
65
+ end
66
+
67
+ def success?
68
+ !error? && !container_id.nil?
69
+ end
70
+
71
+ def file_container
72
+ payload&.item
73
+ end
74
+
75
+ def container_id
76
+ file_container&.id
77
+ end
78
+ end
34
79
 
35
80
  def initialize(graphql_client)
36
- @graphql_client = graphql_client
37
- @http_client = graphql_client.client.http_client
38
- @org_id = @http_client.org_id
39
- @host = @http_client.host
81
+ @graphql = graphql_client
82
+ @hooks = {}
40
83
  end
41
84
 
42
- # Upload a file and return the fileContainerId.
43
- # Raises RuntimeError on failure.
44
- def upload(file_path)
45
- raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
85
+ # Register a stage hook. Called for every file, in that file's own thread — keep it
86
+ # thread-safe (or wrap it in your own mutex).
87
+ # @param stage [:signature, :storage, :register]
88
+ def on(stage, &block)
89
+ stage = stage.to_sym
90
+ raise ArgumentError, "unknown stage #{stage.inspect}; expected one of #{STAGES.join(', ')}" unless STAGES.include?(stage)
46
91
 
47
- creds = fetch_s3_credentials
48
- s3_key = direct_upload_to_storage(file_path, creds)
49
- poll_id = register_upload(file_path, s3_key)
50
- poll_for_container_id(poll_id)
92
+ mutex(:hooks).synchronize { (@hooks[stage] ||= []) << block }
93
+ self
51
94
  end
52
95
 
53
- private
96
+ # @return [String] the file container id.
97
+ # @raise [Error] on any failure — use {#upload_all} for non-raising per-file isolation.
98
+ def upload(file_path, **kargs)
99
+ result = upload_one(file_path, **kargs)
100
+ raise result.error if result.error?
54
101
 
55
- # Step 1: GraphQL query → S3 presigned credentials
56
- def fetch_s3_credentials
57
- Query::FileUploadSignature.new(@graphql_client.client).query
102
+ result.container_id
58
103
  end
59
104
 
60
- # Step 2: Direct multipart POST to S3
61
- def direct_upload_to_storage(file_path, creds)
62
- filename = File.basename(file_path)
63
- s3_key = "#{creds.upload_prefix}#{filename}"
64
- mime_type = detect_mime_type(file_path)
65
- endpoint = URI(creds.endpoint)
66
-
67
- File.open(file_path, 'rb') do |file|
68
- Net::HTTP.start(endpoint.host, endpoint.port,
69
- use_ssl: endpoint.scheme == 'https') do |http|
70
- boundary = "EcoPortalUpload#{SecureRandom.hex(8)}"
71
- body = build_multipart(boundary, {
72
- s3_key: s3_key, access_key: creds.access_key,
73
- policy: creds.policy, signature: creds.signature,
74
- mime_type: mime_type, filename: filename
75
- }, file)
76
- request = Net::HTTP::Post.new(endpoint.path.empty? ? '/' : endpoint.path)
77
- request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
78
- request.body = body
79
- response = http.request(request)
80
- raise "S3 upload failed (#{response.code}): #{response.body}" unless response.code.to_i < 300
105
+ # Uploads many files with bounded concurrency. Never raises for a single file: each
106
+ # {Result} carries its own error, and the block is called as each finishes.
107
+ #
108
+ # @param threads [Integer] max concurrent uploads (`1` = inline, deterministic).
109
+ # @return [Array<Result>] one per input file; order is not guaranteed when threaded.
110
+ def upload_all(file_paths, threads: 4, **kargs, &block)
111
+ files = Array(file_paths).flatten.compact
112
+ max = threads.to_i.clamp(1, MAX_THREADS)
113
+ results = []
114
+ spawned = []
115
+
116
+ # Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
117
+ # here so N threads don't race for the first one.
118
+ credentials
119
+
120
+ with_preserved_thread_globals do
121
+ files.each do |file|
122
+ new_thread(spawned, max: max) do
123
+ result = upload_one(file, **kargs)
124
+ mutex(:results).synchronize { results << result }
125
+ block&.call(result)
126
+ end
81
127
  end
82
128
  end
83
129
 
84
- s3_key
130
+ spawned.each(&:join)
131
+ results
85
132
  end
86
133
 
87
- # Step 3: Register with EcoPortal backend (REST v2)
88
- def register_upload(file_path, s3_key)
89
- filename = File.basename(file_path)
90
- mime_type = detect_mime_type(file_path)
91
- filesize = File.size(file_path)
92
-
93
- body = { filename: filename, filetype: mime_type, filesize: filesize, path: s3_key }
94
- response = upload_post('/s3/files', body)
95
- raise "File registration failed: #{response.body}" unless response.code.to_i < 300
96
-
97
- JSON.parse(response.body)['id']
134
+ # Force the next upload to presign again (e.g. after a policy expiry).
135
+ def refresh_credentials!
136
+ mutex(:credentials).synchronize { @credentials = nil }
137
+ self
98
138
  end
99
139
 
100
- # Step 4: Poll until FileContainer is created; return fileContainerId
101
- def poll_for_container_id(poll_id)
102
- deadline = Time.now + POLL_MAX_WAIT
103
- loop do
104
- raise 'File upload timed out' if Time.now > deadline
105
-
106
- response = upload_get("/s3/files/poll/#{poll_id}")
107
- raise "Poll failed: #{response.body}" unless response.code.to_i < 300
108
-
109
- data = JSON.parse(response.body)
110
- status = data['status'] || data.dig('file', 'status')
111
-
112
- return data.dig('file', 'file_container_id') if data['status'] == 'success'
113
- raise "File upload failed: #{data.inspect}" if status == 'failed'
140
+ private
114
141
 
115
- sleep POLL_INTERVAL
142
+ def upload_one(file_path, location_ids: nil, tags: nil, container_id: nil)
143
+ Result.new(file_path).tap do |result|
144
+ raise MissingLocalFile, "no such file: #{file_path}" unless ::File.file?(file_path)
145
+
146
+ creds = credentials
147
+ result.key = storage_key(creds, file_path)
148
+ store!(creds, result.key, file_path)
149
+ result.payload = register(
150
+ file_path, result.key,
151
+ location_ids: location_ids, tags: tags, container_id: container_id
152
+ )
153
+ rescue StandardError => e
154
+ result.error = e
116
155
  end
117
156
  end
118
157
 
119
- # --- REST helpers (v2 endpoints, X-ECOPORTAL-API-KEY auth) ---
120
-
121
- def api_key
122
- @api_key ||= @http_client.api_key.tap do |key|
123
- if key.nil? || key.to_s.strip.empty?
124
- raise 'File upload requires an api_key: Ecoportal::API::GraphQL.new(email:, pass:, api_key:). ' \
125
- 'The v2 REST endpoints used to register/poll an upload authenticate via ' \
126
- 'X-ECOPORTAL-API-KEY, not the GraphQL OAuth bearer token.'
158
+ # Memoised behind a mutex so a batch presigns once, not once per thread.
159
+ def credentials
160
+ mutex(:credentials).synchronize do
161
+ @credentials ||= Query::FileUploadSignature.new(@graphql.client).query.tap do |creds|
162
+ fire(:signature, creds)
127
163
  end
128
164
  end
129
165
  end
130
166
 
131
- def upload_base_url
132
- "https://#{@host}/api/v2/#{@org_id}"
167
+ # `uploads/<userId>/<epoch-ms>-<token>-/<basename>` — the shape the web client uses.
168
+ # That stamp+token segment is what stops same-named files colliding: the previous
169
+ # implementation concatenated `upload_prefix + filename`, so two uploads of
170
+ # `report.pdf` overwrote each other in the bucket.
171
+ def storage_key(creds, file_path)
172
+ stamp = (Time.now.to_f * 1000).to_i
173
+ "#{creds.upload_prefix}#{stamp}-#{SecureRandom.alphanumeric(20)}-/#{::File.basename(file_path)}"
133
174
  end
134
175
 
135
- def upload_get(path)
136
- uri = URI("#{upload_base_url}#{path}")
137
- request = Net::HTTP::Get.new(uri)
138
- request['X-ECOPORTAL-API-KEY'] = api_key
139
- request['Accept'] = 'application/json'
140
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
176
+ def store!(creds, key, file_path)
177
+ uri = URI.parse(creds.endpoint)
178
+ type = mime_type(file_path)
179
+
180
+ ::File.open(file_path, 'rb') do |io|
181
+ req = Net::HTTP::Post.new(uri)
182
+ # Field ORDER matches the browser's form. `set_form` builds the multipart body
183
+ # (as ecoportal-api-v2 does) rather than hand-rolling boundaries.
184
+ req.set_form(
185
+ [
186
+ ['key', key],
187
+ ['AWSAccessKeyId', creds.access_key],
188
+ ['policy', creds.policy],
189
+ ['signature', creds.signature],
190
+ ['content-type', type],
191
+ ['x-amz-server-side-encryption', encryption(creds)],
192
+ ['file', io, {filename: ::File.basename(file_path), content_type: type}]
193
+ ],
194
+ 'multipart/form-data'
195
+ )
196
+
197
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
198
+ http.request(req)
199
+ end
200
+
201
+ fire(:storage, key, response)
202
+ return response if response.is_a?(Net::HTTPSuccess)
203
+
204
+ raise StorageUploadFailed,
205
+ "storage rejected #{::File.basename(file_path)}: #{response.code} #{response.body.to_s[0, 300]}"
206
+ end
141
207
  end
142
208
 
143
- def upload_post(path, body)
144
- uri = URI("#{upload_base_url}#{path}")
145
- request = Net::HTTP::Post.new(uri)
146
- request['X-ECOPORTAL-API-KEY'] = api_key
147
- request['Content-Type'] = 'application/json'
148
- request['Accept'] = 'application/json'
149
- request.body = JSON.generate(body)
150
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
209
+ def register(file_path, key, location_ids:, tags:, container_id:)
210
+ payload = Mutation::FileContainer::Upload.new(@graphql.client).query(
211
+ filename: ::File.basename(file_path),
212
+ filetype: mime_type(file_path),
213
+ filesize: ::File.size(file_path),
214
+ url: key,
215
+ location_ids: location_ids,
216
+ other_tags: tags,
217
+ container_id: container_id
218
+ )
219
+ fire(:register, payload)
220
+
221
+ raise RegistrationFailed, "uploadFile failed for #{::File.basename(file_path)}: #{payload.error_doc}" if payload.error?
222
+ raise RegistrationFailed, "uploadFile returned no container for #{::File.basename(file_path)}" unless payload.item&.id
223
+
224
+ payload
151
225
  end
152
226
 
153
- # --- Multipart builder ---
154
-
155
- def build_multipart(boundary, opts, file_io)
156
- parts = {
157
- 'key' => opts[:s3_key],
158
- 'AWSAccessKeyId' => opts[:access_key],
159
- 'policy' => opts[:policy],
160
- 'signature' => opts[:signature],
161
- 'x-amz-server-side-encryption' => 'AES256',
162
- 'Content-Type' => opts[:mime_type]
163
- }
164
- filename = opts[:filename]
165
- mime_type = opts[:mime_type]
166
-
167
- body = +''
168
- parts.each do |name, value|
169
- body << "--#{boundary}\r\n"
170
- body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
171
- body << "#{value}\r\n"
172
- end
173
- # File field last
174
- body << "--#{boundary}\r\n"
175
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
176
- body << "Content-Type: #{mime_type}\r\n\r\n"
177
- body << file_io.read
178
- body << "\r\n--#{boundary}--\r\n"
179
- body
227
+ # The bucket policy dictates the encryption header, so echo it instead of assuming:
228
+ # the policy is base64 JSON whose `conditions` carry `x-amz-server-side-encryption`.
229
+ def encryption(creds)
230
+ @encryption ||=
231
+ begin
232
+ conditions = JSON.parse(Base64.decode64(creds.policy.to_s))['conditions'] || []
233
+ conditions.grep(Hash).filter_map { |c| c['x-amz-server-side-encryption'] }.first || DEFAULT_ENCRYPTION
234
+ rescue JSON::ParserError, TypeError
235
+ DEFAULT_ENCRYPTION
236
+ end
237
+ end
238
+
239
+ def fire(stage, *args)
240
+ hooks = mutex(:hooks).synchronize { @hooks[stage]&.dup }
241
+ (hooks || []).each { |hook| hook.call(*args) }
180
242
  end
181
243
 
182
- def detect_mime_type(file_path)
183
- types = MIME::Types.type_for(file_path)
184
- types.first&.to_s || 'application/octet-stream'
185
- rescue StandardError
186
- 'application/octet-stream'
244
+ def mime_type(file_path)
245
+ MIME::Types.type_for(file_path).first&.content_type || DEFAULT_MIME
187
246
  end
188
247
  end
189
248
  end
@@ -0,0 +1,84 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ class Fragment
5
+ # Common fields of the `ActivityInterface` (page history feed). Selecting on
6
+ # the interface returns these for every one of the ~61 concrete activity
7
+ # types; `__typename` lets callers dispatch. We deliberately do NOT pull in
8
+ # per-type fields (kept to the interface surface + a __typename passthrough,
9
+ # so any unmodelled activity type still resolves cleanly as Model::Activity).
10
+ fragment :Activity, <<~GRAPHQL
11
+ fragment Activity on ActivityInterface {
12
+ __typename
13
+ id
14
+ priority
15
+ system
16
+ fromPublicTemplate
17
+ stageId
18
+ timeZone
19
+ categories
20
+ subkey
21
+ sourceId
22
+ sourceName
23
+ userId
24
+ userEmail
25
+ userName
26
+ createdAt {
27
+ dateTime
28
+ timeZone
29
+ }
30
+ updatedAt {
31
+ dateTime
32
+ timeZone
33
+ }
34
+ personMember {
35
+ id
36
+ name
37
+ email
38
+ }
39
+ user {
40
+ id
41
+ email
42
+ name
43
+ }
44
+ }
45
+ GRAPHQL
46
+
47
+ # `Organization.page(id:)` returns a PageUnion (BasicPage | PhasedPage), so the
48
+ # `activities` connection can only be selected inside inline fragments per member.
49
+ # This fragment wraps the ActivityInterfaceConnection selection for both page
50
+ # types; it references the operation variables ($searchConf/$after/$before/
51
+ # $first/$last), which are declared by Query::PageActivities.
52
+ fragment :PageActivities, <<~GRAPHQL
53
+ fragment PageActivities on PageUnion {
54
+ __typename
55
+ ... on BasicPage {
56
+ activities(searchConf: $searchConf, after: $after, before: $before, first: $first, last: $last) {
57
+ ...ActivityConnection
58
+ }
59
+ }
60
+ ... on PhasedPage {
61
+ activities(searchConf: $searchConf, after: $after, before: $before, first: $first, last: $last) {
62
+ ...ActivityConnection
63
+ }
64
+ }
65
+ }
66
+ GRAPHQL
67
+
68
+ # The ActivityInterfaceConnection selection reused by both page types above.
69
+ fragment :ActivityConnection, <<~GRAPHQL
70
+ fragment ActivityConnection on ActivityInterfaceConnection {
71
+ totalCount
72
+ pageInfo {
73
+ endCursor
74
+ hasNextPage
75
+ }
76
+ nodes {
77
+ ...Activity
78
+ }
79
+ }
80
+ GRAPHQL
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,75 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ class Fragment
5
+ # System Access Log entry — `currentOrganization.activityLogs` returns an
6
+ # `ActivityLogUnion` (a UNION), so fields cannot be selected on the union
7
+ # directly (only `__typename`): every field must live in an inline fragment
8
+ # per member. The 9 members share a common field set; the Dashboard* members
9
+ # additionally expose `parentResource*` + `resource { isVisible }`.
10
+ #
11
+ # To avoid repeating the shared selection 9 times we spread two helper
12
+ # fragments (:ActivityLogCommon on each member, :ActivityLogResource for the
13
+ # Dashboard members). Model::ActivityLog maps every member uniformly via its
14
+ # `__typename` passthrough, so an unmodelled/new member still resolves.
15
+ fragment :ActivityLog, <<~GRAPHQL
16
+ fragment ActivityLog on ActivityLogUnion {
17
+ __typename
18
+ ... on DashboardAccess { ...ActivityLogCommon ...ActivityLogResource }
19
+ ... on DashboardCreate { ...ActivityLogCommon ...ActivityLogResource }
20
+ ... on DashboardDelete { ...ActivityLogCommon ...ActivityLogResource }
21
+ ... on DashboardUpdate { ...ActivityLogCommon ...ActivityLogResource }
22
+ ... on DeniedDashboardAccess { ...ActivityLogCommon ...ActivityLogResource }
23
+ ... on DeniedPageAccess { ...ActivityLogCommon }
24
+ ... on PageAccess { ...ActivityLogCommon }
25
+ ... on FileDownload { ...ActivityLogCommon }
26
+ ... on ScimIntegrationAccess { ...ActivityLogCommon }
27
+ }
28
+ GRAPHQL
29
+
30
+ # Fields common to ALL ActivityLogUnion members. Spread inside each member's
31
+ # inline fragment. `__fragment_type` is intentionally omitted; the union-level
32
+ # `__typename` is selected in :ActivityLog above.
33
+ fragment :ActivityLogCommon, <<~GRAPHQL
34
+ fragment ActivityLogCommon on BaseActivityLogInterface {
35
+ id
36
+ activityType
37
+ source
38
+ userType
39
+ accountStatus
40
+ becomingEmail
41
+ content
42
+ resourceId
43
+ resourceName
44
+ resourceType
45
+ createdAt {
46
+ dateTime
47
+ timeZone
48
+ }
49
+ user {
50
+ id
51
+ email
52
+ name
53
+ }
54
+ }
55
+ GRAPHQL
56
+
57
+ # Dashboard-member-only fields (parent + visibility of the dashboard resource).
58
+ fragment :ActivityLogResource, <<~GRAPHQL
59
+ fragment ActivityLogResource on BaseDashboardActivityLogInterface {
60
+ parentResourceId
61
+ parentResourceName
62
+ parentResourceType
63
+ resource {
64
+ __typename
65
+ isVisible {
66
+ value
67
+ message
68
+ }
69
+ }
70
+ }
71
+ GRAPHQL
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ class Fragment
5
+ fragment :AuthLog, <<~GRAPHQL
6
+ fragment AuthLog on AuthLog {
7
+ id
8
+ event
9
+ source
10
+ loginMethod
11
+ host
12
+ ipAddress
13
+ referrer
14
+ userAgent
15
+ createdAt {
16
+ dateTime
17
+ timeZone
18
+ }
19
+ targetUser {
20
+ id
21
+ email
22
+ name
23
+ }
24
+ }
25
+ GRAPHQL
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,14 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ class Fragment
5
+ fragment :AuthorizationResult, <<~GRAPHQL
6
+ fragment AuthorizationResult on AuthorizationResult {
7
+ value
8
+ message
9
+ }
10
+ GRAPHQL
11
+ end
12
+ end
13
+ end
14
+ end
@@ -17,6 +17,7 @@ module Ecoportal
17
17
  active
18
18
  id
19
19
  name
20
+ aiGenerated
20
21
  }
21
22
  }
22
23
  GRAPHQL
@@ -0,0 +1,31 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ class Fragment
5
+ fragment :MinimalDashboard, <<~GRAPHQL
6
+ fragment MinimalDashboard on MinimalDashboard {
7
+ id
8
+ name
9
+ global
10
+ weight
11
+ parentResource
12
+ parentResourceId
13
+ parentResourceType
14
+ permissionType
15
+ owner {
16
+ id
17
+ email
18
+ name
19
+ }
20
+ register {
21
+ id
22
+ name
23
+ iconName
24
+ filterTags
25
+ }
26
+ }
27
+ GRAPHQL
28
+ end
29
+ end
30
+ end
31
+ end