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