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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +384 -6
- data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +97 -0
- data/lib/ecoportal/api/common/graphql/model/diffable.rb +1 -0
- data/lib/ecoportal/api/graphql/base/action.rb +1 -1
- data/lib/ecoportal/api/graphql/base/action_event.rb +23 -0
- data/lib/ecoportal/api/graphql/base/action_event_page.rb +16 -0
- data/lib/ecoportal/api/graphql/base/activity.rb +38 -0
- data/lib/ecoportal/api/graphql/base/activity_log.rb +38 -0
- data/lib/ecoportal/api/graphql/base/ai_summary_version.rb +12 -7
- data/lib/ecoportal/api/graphql/base/auth_log.rb +24 -0
- data/lib/ecoportal/api/graphql/base/authorization_result.rb +20 -0
- data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +17 -5
- data/lib/ecoportal/api/graphql/base/file_container.rb +12 -4
- data/lib/ecoportal/api/graphql/base/location_classification_type.rb +2 -0
- data/lib/ecoportal/api/graphql/base/location_structure.rb +3 -0
- data/lib/ecoportal/api/graphql/base/minimal_dashboard.rb +25 -0
- data/lib/ecoportal/api/graphql/base/organization.rb +2 -0
- data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +26 -3
- data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +47 -8
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +5 -2
- data/lib/ecoportal/api/graphql/base/page/data_field/people.rb +50 -12
- data/lib/ecoportal/api/graphql/base/page/data_field.rb +12 -18
- data/lib/ecoportal/api/graphql/base/permissions.rb +15 -0
- data/lib/ecoportal/api/graphql/base/person_member_changelog.rb +23 -0
- data/lib/ecoportal/api/graphql/base/preset_view.rb +136 -1
- data/lib/ecoportal/api/graphql/base/register.rb +5 -0
- data/lib/ecoportal/api/graphql/base.rb +9 -0
- data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +10 -0
- data/lib/ecoportal/api/graphql/builder/page.rb +0 -16
- data/lib/ecoportal/api/graphql/builder/template.rb +18 -0
- data/lib/ecoportal/api/graphql/compat/pages.rb +7 -2
- data/lib/ecoportal/api/graphql/compat/response.rb +14 -0
- data/lib/ecoportal/api/graphql/concerns/threadable.rb +58 -0
- data/lib/ecoportal/api/graphql/concerns.rb +1 -0
- data/lib/ecoportal/api/graphql/connection/action.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/action_category.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/activity.rb +11 -0
- data/lib/ecoportal/api/graphql/connection/activity_log.rb +11 -0
- data/lib/ecoportal/api/graphql/connection/auth_log.rb +11 -0
- data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/minimal_dashboard.rb +11 -0
- data/lib/ecoportal/api/graphql/connection/page.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/pages_workflow_command.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/person_member.rb +1 -1
- data/lib/ecoportal/api/graphql/connection/person_member_changelog.rb +11 -0
- data/lib/ecoportal/api/graphql/connection/preview_page.rb +1 -1
- data/lib/ecoportal/api/graphql/connection.rb +5 -0
- data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +21 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +199 -140
- data/lib/ecoportal/api/graphql/fragment/activity.rb +84 -0
- data/lib/ecoportal/api/graphql/fragment/activity_log.rb +75 -0
- data/lib/ecoportal/api/graphql/fragment/auth_log.rb +29 -0
- data/lib/ecoportal/api/graphql/fragment/authorization_result.rb +14 -0
- data/lib/ecoportal/api/graphql/fragment/location_node.rb +1 -0
- data/lib/ecoportal/api/graphql/fragment/minimal_dashboard.rb +31 -0
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +21 -0
- data/lib/ecoportal/api/graphql/fragment/permissions.rb +98 -0
- data/lib/ecoportal/api/graphql/fragment/person_member_changelog.rb +33 -0
- data/lib/ecoportal/api/graphql/fragment.rb +7 -0
- data/lib/ecoportal/api/graphql/input/action/update.rb +12 -0
- data/lib/ecoportal/api/graphql/input/contractor_entity/manager_settings.rb +20 -0
- data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +6 -27
- data/lib/ecoportal/api/graphql/input/contractor_entity.rb +1 -0
- data/lib/ecoportal/api/graphql/input/date_range.rb +16 -0
- data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +2 -2
- data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +2 -2
- data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +6 -2
- data/lib/ecoportal/api/graphql/input/preset_view/update.rb +16 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/add_checklist_field_item.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/add_smart_fill_config.rb +23 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/add_viewable_field.rb +45 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/archive_public_config.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/create_public_config.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_checklist_field_item.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +12 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_public_config.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_smart_fill_config.rb +21 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_advanced_settings.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_basic_settings.rb +27 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +45 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/{edit_page_creator_permissions.rb → edit_template_creator_permissions.rb} +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_descriptions.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/action_list.rb +31 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +3 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +15 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/file.rb +21 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/people.rb +27 -3
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/move_viewable_field.rb +33 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/remove_checklist_field_item.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/remove_smart_fill_config.rb +19 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/remove_viewable_field.rb +21 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +3 -2
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +43 -5
- data/lib/ecoportal/api/graphql/input.rb +1 -0
- data/lib/ecoportal/api/graphql/interface/base_page.rb +36 -3
- data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +2 -1
- data/lib/ecoportal/api/graphql/logic/connection.rb +17 -0
- data/lib/ecoportal/api/graphql/logic/input.rb +127 -0
- data/lib/ecoportal/api/graphql/model/action_event.rb +14 -0
- data/lib/ecoportal/api/graphql/model/action_event_page.rb +10 -0
- data/lib/ecoportal/api/graphql/model/activity.rb +14 -0
- data/lib/ecoportal/api/graphql/model/activity_log.rb +31 -0
- data/lib/ecoportal/api/graphql/model/auth_log.rb +11 -0
- data/lib/ecoportal/api/graphql/model/authorization_result.rb +12 -0
- data/lib/ecoportal/api/graphql/model/minimal_dashboard.rb +14 -0
- data/lib/ecoportal/api/graphql/model/organization.rb +27 -0
- data/lib/ecoportal/api/graphql/model/permissions.rb +179 -0
- data/lib/ecoportal/api/graphql/model/person_member.rb +7 -0
- data/lib/ecoportal/api/graphql/model/person_member_changelog.rb +15 -0
- data/lib/ecoportal/api/graphql/model.rb +9 -0
- data/lib/ecoportal/api/graphql/mutation/ai_summary/generate.rb +20 -10
- data/lib/ecoportal/api/graphql/mutation/ai_summary/submit_feedback.rb +25 -10
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/update_manager_settings.rb +37 -0
- data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +78 -0
- data/lib/ecoportal/api/graphql/mutation/file_container.rb +15 -0
- data/lib/ecoportal/api/graphql/mutation/template/update_related_page.rb +79 -0
- data/lib/ecoportal/api/graphql/mutation/template.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation.rb +1 -1
- data/lib/ecoportal/api/graphql/payload/contractor_entity/manager_settings.rb +20 -0
- data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/template/update_related_page.rb +15 -0
- data/lib/ecoportal/api/graphql/payload/template.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/upload.rb +12 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql/query/activity_logs.rb +78 -0
- data/lib/ecoportal/api/graphql/query/auth_logs.rb +51 -0
- data/lib/ecoportal/api/graphql/query/dashboard_search.rb +57 -0
- data/lib/ecoportal/api/graphql/query/location_structure.rb +2 -0
- data/lib/ecoportal/api/graphql/query/location_structures.rb +2 -0
- data/lib/ecoportal/api/graphql/query/page.rb +6 -1
- data/lib/ecoportal/api/graphql/query/page_activities.rb +56 -0
- data/lib/ecoportal/api/graphql/query/permissions.rb +35 -0
- data/lib/ecoportal/api/graphql/query/person_member_changelogs.rb +53 -0
- data/lib/ecoportal/api/graphql/query.rb +6 -0
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +73 -12
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +0 -28
- data/lib/ecoportal/api/graphql/mutation/smart_fill/generate.rb +0 -36
- data/lib/ecoportal/api/graphql/mutation/smart_fill/submit_feedback.rb +0 -40
- data/lib/ecoportal/api/graphql/mutation/smart_fill.rb +0 -13
|
@@ -1,189 +1,248 @@
|
|
|
1
|
-
|
|
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
|
-
#
|
|
12
|
+
# Uploads local files to the org file manager, natively.
|
|
11
13
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@org_id = @http_client.org_id
|
|
39
|
-
@host = @http_client.host
|
|
81
|
+
@graphql = graphql_client
|
|
82
|
+
@hooks = {}
|
|
40
83
|
end
|
|
41
84
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
def fetch_s3_credentials
|
|
57
|
-
Query::FileUploadSignature.new(@graphql_client.client).query
|
|
102
|
+
result.container_id
|
|
58
103
|
end
|
|
59
104
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
130
|
+
spawned.each(&:join)
|
|
131
|
+
results
|
|
85
132
|
end
|
|
86
133
|
|
|
87
|
-
#
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
136
|
-
uri
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
#
|
|
154
|
-
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
183
|
-
|
|
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,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
|