ecoportal-api-graphql 1.3.10 → 1.3.12

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/diff_pairing_engine.md +243 -0
  3. data/.ai-assistance/code/filter_contract_matrix.md +177 -0
  4. data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
  5. data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
  6. data/.ai-assistance/code/workflow-command-guide.md +28 -0
  7. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
  8. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
  9. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
  10. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
  11. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
  12. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
  13. data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
  14. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +87 -0
  15. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +108 -0
  16. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
  17. data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
  18. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
  19. data/.ai-assistance/projects/template-maintenance/README.md +14 -0
  20. data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
  21. data/.ai-assistance/standards-version.json +21 -20
  22. data/CHANGELOG.md +119 -0
  23. data/CLAUDE.md +10 -0
  24. data/docs/self-docs/ARCHITECTURE.md +88 -0
  25. data/docs/self-docs/CHANGES.jsonl +7 -0
  26. data/docs/self-docs/CONVENTIONS.md +74 -0
  27. data/docs/self-docs/INTEGRATIONS.md +63 -0
  28. data/docs/self-docs/OVERVIEW.md +51 -0
  29. data/docs/self-docs/STATUS.md +69 -0
  30. data/docs/self-docs/self-docs-index.json +39 -0
  31. data/docs/worklog.md +256 -0
  32. data/ecoportal-api-graphql.gemspec +1 -1
  33. data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
  34. data/lib/ecoportal/api/graphql/base/action.rb +43 -43
  35. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
  36. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
  37. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
  38. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
  39. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
  40. data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
  41. data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
  42. data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
  43. data/lib/ecoportal/api/graphql/base/register.rb +18 -18
  44. data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
  45. data/lib/ecoportal/api/graphql/builder.rb +17 -16
  46. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
  47. data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
  48. data/lib/ecoportal/api/graphql/concerns.rb +14 -14
  49. data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
  50. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
  51. data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
  52. data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
  53. data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
  54. data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
  55. data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
  56. data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
  57. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
  58. data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
  59. data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
  60. data/lib/ecoportal/api/graphql/diff.rb +34 -0
  61. data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
  62. data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
  63. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
  64. data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
  65. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
  66. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
  67. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
  68. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
  69. data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
  70. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
  71. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
  72. data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
  73. data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
  74. data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
  75. data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
  76. data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
  77. data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
  78. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
  79. data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
  80. data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
  81. data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
  82. data/lib/ecoportal/api/graphql.rb +1 -0
  83. data/lib/ecoportal/api/graphql_version.rb +1 -1
  84. data/tests/dump_template_model.rb +90 -0
  85. data/tests/validate_queries.rb +31 -9
  86. metadata +40 -3
@@ -1,79 +1,85 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Base
5
- module Page
6
- # Wraps an array of raw section docs as typed Section objects.
7
- # Provides v2-compatible access patterns:
8
- # sections.get_by_type(:content_section)
9
- # sections.doc — raw doc array
10
- # sections.ordered — sections in response order
11
- # sections.each { |s| s.components ... }
12
- class SectionCollection
13
- include Enumerable
14
-
15
- def initialize(sections_array)
16
- @sections = Array(sections_array).map { |s| Section.new(s) }
17
- end
18
-
19
- def self.from_doc(raw_array)
20
- new(Array(raw_array))
21
- end
22
-
23
- # Filter by section type. Accepts symbol (:content_section / :split_section)
24
- # or string ('ContentSection' / 'SplitSection'). Case-insensitive, underscore-tolerant.
25
- def get_by_type(type_key)
26
- target = normalise_type(type_key)
27
- @sections.select { |s| normalise_type(s.type) == target }
28
- end
29
-
30
- # Find section by heading (case-insensitive).
31
- def get_by_heading(heading)
32
- @sections.find { |s| s.heading.to_s.downcase == heading.to_s.downcase }
33
- end
34
-
35
- # Raw doc array v2 compat (scripts iterate sections.doc).
36
- def doc
37
- @sections.map(&:doc)
38
- end
39
-
40
- # Sections in response order (already ordered from server).
41
- def ordered
42
- @sections
43
- end
44
-
45
- # All data fields across every section in this collection (memoized).
46
- def components
47
- @components ||= begin
48
- all_docs = @sections.flat_map do |s|
49
- (s.doc['dataFields'] || []) +
50
- (s.doc['leftDataFields'] || []) +
51
- (s.doc['rightDataFields'] || [])
52
- end
53
- DataField::Collection.from_doc(all_docs)
54
- end
55
- end
56
-
57
- def each(&block)
58
- @sections.each(&block)
59
- end
60
-
61
- def empty?
62
- @sections.empty?
63
- end
64
-
65
- def length
66
- @sections.length
67
- end
68
-
69
- private
70
-
71
- def normalise_type(type_key)
72
- type_key.to_s.downcase.delete('_') if type_key
73
- end
74
- end
75
- end
76
- end
77
- end
78
- end
79
- end
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ module Page
6
+ # Wraps an array of raw section docs as typed Section objects.
7
+ # Provides v2-compatible access patterns:
8
+ # sections.get_by_type(:content_section)
9
+ # sections.doc — raw doc array
10
+ # sections.ordered — sections in response order
11
+ # sections.each { |s| s.components ... }
12
+ class SectionCollection
13
+ include Enumerable
14
+
15
+ def initialize(sections_array)
16
+ @sections = Array(sections_array).map { |s| Section.new(s) }
17
+ end
18
+
19
+ def self.from_doc(raw_array)
20
+ new(Array(raw_array))
21
+ end
22
+
23
+ # Filter by section type. Accepts symbol (:content_section / :split_section)
24
+ # or string ('ContentSection' / 'SplitSection'). Case-insensitive, underscore-tolerant.
25
+ def get_by_type(type_key)
26
+ target = normalise_type(type_key)
27
+ @sections.select { |s| normalise_type(s.type) == target }
28
+ end
29
+
30
+ # Find section by heading (case-insensitive).
31
+ def get_by_heading(heading)
32
+ @sections.find { |s| s.heading.to_s.downcase == heading.to_s.downcase }
33
+ end
34
+
35
+ # Find a section by its id (v2 compat: sections.get_by_id). Returns the section or nil.
36
+ def get_by_id(id)
37
+ wanted = id.to_s
38
+ @sections.find { |s| s.id.to_s == wanted }
39
+ end
40
+
41
+ # Raw doc array — v2 compat (scripts iterate sections.doc).
42
+ def doc
43
+ @sections.map(&:doc)
44
+ end
45
+
46
+ # Sections in response order (already ordered from server).
47
+ def ordered
48
+ @sections
49
+ end
50
+
51
+ # All data fields across every section in this collection (memoized).
52
+ def components
53
+ @components ||= begin
54
+ all_docs = @sections.flat_map do |s|
55
+ (s.doc['dataFields'] || []) +
56
+ (s.doc['leftDataFields'] || []) +
57
+ (s.doc['rightDataFields'] || [])
58
+ end
59
+ DataField::Collection.from_doc(all_docs)
60
+ end
61
+ end
62
+
63
+ def each(&block)
64
+ @sections.each(&block)
65
+ end
66
+
67
+ def empty?
68
+ @sections.empty?
69
+ end
70
+
71
+ def length
72
+ @sections.length
73
+ end
74
+
75
+ private
76
+
77
+ def normalise_type(type_key)
78
+ type_key.to_s.downcase.delete('_') if type_key
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,17 +1,17 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Base
5
- # A register preset view — saved column/sort/filter configuration for previewPages.
6
- # Scripts use the id as `presetViewId` in register.previewPages queries.
7
- class PresetView < Logic::BaseModel
8
- passkey :id
9
- passthrough :name, :registerId
10
- passboolean :global
11
- passarray :fieldConfigurations
12
- passthrough :sorter # { key:, direction:, missing: }
13
- end
14
- end
15
- end
16
- end
17
- end
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # A register preset view — saved column/sort/filter configuration for previewPages.
6
+ # Scripts use the id as `presetViewId` in register.previewPages queries.
7
+ class PresetView < Logic::BaseModel
8
+ passkey :id
9
+ passthrough :name, :registerId
10
+ passboolean :global
11
+ passarray :fieldConfigurations
12
+ passthrough :sorter # { key:, direction:, missing: }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,18 +1,18 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Base
5
- class Register < Logic::BaseModel
6
- passkey :id
7
- passthrough :name, :iconName, :moduleType, :registerStatus
8
- passthrough :descriptionText, :descriptionImageUrl
9
- passboolean :shown, :creatable, :attachable, :permissioned
10
- passboolean :contentShareable, :enableEmailOut, :isMobile
11
- passboolean :restrictTemplatesAmount
12
- passthrough :restrictedAmountOfTemplates
13
- passarray :filterTags
14
- end
15
- end
16
- end
17
- end
18
- end
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ class Register < Logic::BaseModel
6
+ passkey :id
7
+ passthrough :name, :iconName, :moduleType, :registerStatus
8
+ passthrough :descriptionText, :descriptionImageUrl
9
+ passboolean :shown, :creatable, :attachable, :permissioned
10
+ passboolean :contentShareable, :enableEmailOut, :isMobile
11
+ passboolean :restrictTemplatesAmount
12
+ passthrough :restrictedAmountOfTemplates
13
+ passarray :filterTags
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,174 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Builder
5
+ # Build-from-scratch emitter for a template/workflow: turns a DECLARATIVE spec
6
+ # (stages -> sections -> fields -> options / config / gauge-stops) into an ordered batch of
7
+ # built `WorkflowCommand` hashes, ready for `Builder::Template#create(commands:)` /
8
+ # `#update(model, commands:)` (which wrap `executeWorkflowCommands`).
9
+ #
10
+ # This is the gem-level, reusable BUILD emitter (previously a one-off in eco-helpers samples).
11
+ # It shares ONE emission layer with the DIFF-DEPLOY pipeline: both produce an ordered
12
+ # `WorkflowCommand` batch, and both use the SAME `placeholderId` id-threading primitive that
13
+ # `Diff::CommandSynthesizer` uses — a newly-created node is addressed by a client-chosen
14
+ # `placeholderId`, and every later command in the same batch that references it substitutes
15
+ # that placeholder (the server has not assigned real ids yet). Here every node is brand new,
16
+ # so the whole batch is threaded through placeholders.
17
+ #
18
+ # SPEC SHAPE (all keys optional unless noted; only VALID command inputs are emitted):
19
+ #
20
+ # {
21
+ # stages: [
22
+ # { name: 'Report', ordering: 0, sections: [ # addStage(name, ordering)
23
+ # { layout: 'ONE_COLUMN', fields: [ # addSection(layout) + addStageSection
24
+ # { field_type: 'PlainText', label: 'Description', column: 0,
25
+ # description: 'ID:token' }, # addField (+ editFieldConfiguration for description)
26
+ # { field_type: 'Select', label: 'Severity',
27
+ # options: [ { label: 'Low', weight: 0 }, ... ], # addSelectFieldOption
28
+ # config: { select: { multiple: false } } }, # editFieldConfiguration(byType:)
29
+ # { field_type: 'Gauge', label: 'Risk',
30
+ # stops: [ { threshold: 0, color: '#0f0' }, ... ], # addGaugeFieldStop
31
+ # config: { gauge: { max: 100.0 } } }
32
+ # ] }
33
+ # ] }
34
+ # ]
35
+ # }
36
+ #
37
+ # ORDER — addStage -> addSection -> addStageSection -> addField -> (addSelectFieldOption |
38
+ # addGaugeFieldStop | editFieldConfiguration). This is the dependency-safe order the workflow
39
+ # command bus expects; placeholderId resolution makes the intra-batch references valid.
40
+ #
41
+ # SAFETY — only keys present in each command input's VALID_KEYS are emitted (the input classes
42
+ # slice + compact). A spec key with no corresponding command input is dropped, not fabricated.
43
+ # `field_type` shape and per-type config (`byType`) are NOT invented: config is passed through
44
+ # under its byType sub-hash exactly as given (the caller supplies confirmed byType keys), and
45
+ # is emitted only for a field that carries one.
46
+ class TemplateBuilder
47
+ # @param spec [Hash] the declarative template spec (see class doc). String or symbol keys.
48
+ def initialize(spec)
49
+ @spec = symbolize(spec || {})
50
+ @commands = []
51
+ @counter = 0
52
+ @built = false
53
+ end
54
+
55
+ # Ordered Array of built command hashes ({ commandKey => input }), placeholder-threaded.
56
+ def commands
57
+ return @commands if @built
58
+
59
+ @built = true
60
+ build_all
61
+ end
62
+
63
+ # Ready to hand to `Builder::Template#create(commands: builder.to_commands)`.
64
+ alias_method :to_commands, :commands
65
+
66
+ def to_h
67
+ { commands: commands }
68
+ end
69
+
70
+ private
71
+
72
+ def build_all
73
+ Array(@spec[:stages]).each_with_index { |stage, idx| emit_stage(stage, idx) }
74
+ @commands
75
+ end
76
+
77
+ def emit_stage(stage, index)
78
+ stage_ph = placeholder(:stg)
79
+ add(:addStage, name: stage[:name], ordering: stage.fetch(:ordering, index), placeholderId: stage_ph)
80
+ Array(stage[:sections]).each { |section| emit_section(section, stage_ph) }
81
+ end
82
+
83
+ def emit_section(section, stage_ph)
84
+ section_ph = placeholder(:sec)
85
+ add(:addSection, layout: section[:layout], placeholderId: section_ph)
86
+ # Attach the freshly-created section to its (freshly-created) stage.
87
+ add(:addStageSection, stageId: stage_ph, sectionId: section_ph)
88
+ emit_section_heading(section, section_ph)
89
+ Array(section[:fields]).each { |field| emit_field(field, stage_ph, section_ph) }
90
+ end
91
+
92
+ # A section's heading is not an addSection input; set it via editSectionHeader after create.
93
+ def emit_section_heading(section, section_ph)
94
+ heading = section[:heading]
95
+ return if heading.nil?
96
+
97
+ add(:editSectionHeader, sectionId: section_ph, header: heading)
98
+ end
99
+
100
+ def emit_field(field, stage_ph, section_ph)
101
+ field_ph = placeholder(:fld)
102
+ add(:addField,
103
+ fieldType: field[:field_type] || field[:fieldType],
104
+ label: field[:label],
105
+ stageId: stage_ph,
106
+ sectionId: section_ph,
107
+ column: field[:column],
108
+ placeholderId: field_ph)
109
+ emit_description(field, field_ph)
110
+ emit_options(field, field_ph)
111
+ emit_stops(field, field_ph)
112
+ emit_config(field, field_ph)
113
+ end
114
+
115
+ # A field's `description` is NOT an addField input (the schema's WorkflowAddFieldInput has
116
+ # no `description` key — VERIFIED against the 20260605 dump). It is a top-level key of
117
+ # editFieldConfiguration, so we persist it with a follow-up editFieldConfiguration
118
+ # addressed to the freshly-created field placeholder. Emitted only when supplied. This is
119
+ # the seam the CSV/hidden-field identity token rides on. NOTE: `required` has no schema
120
+ # input key at all, so it is intentionally NOT emitted (a spec `required:` is ignored).
121
+ def emit_description(field, field_ph)
122
+ description = field[:description]
123
+ return if description.nil?
124
+
125
+ add(:editFieldConfiguration, data_field_id: field_ph, description: description)
126
+ end
127
+
128
+ def emit_options(field, field_ph)
129
+ Array(field[:options]).each do |opt|
130
+ add(:addSelectFieldOption, data_field_id: field_ph, label: opt[:label], weight: opt[:weight])
131
+ end
132
+ end
133
+
134
+ def emit_stops(field, field_ph)
135
+ Array(field[:stops]).each do |stop|
136
+ add(:addGaugeFieldStop, data_field_id: field_ph, threshold: stop[:threshold], color: stop[:color])
137
+ end
138
+ end
139
+
140
+ # Per-type field configuration passed through under its byType sub-hash exactly as supplied
141
+ # (e.g. { select: { multiple: true } }, { gauge: { max: 100.0 } }). Emitted only when
142
+ # present; keys are validated/sliced by the EditFieldConfiguration input, never fabricated.
143
+ def emit_config(field, field_ph)
144
+ config = field[:config]
145
+ return if config.nil? || config.empty?
146
+
147
+ add(:editFieldConfiguration, data_field_id: field_ph, byType: symbolize(config))
148
+ end
149
+
150
+ # Build one command and append it. Reuses the SAME builder the diff synthesizer uses, so
151
+ # BUILD and DIFF-DEPLOY emit through one layer.
152
+ def add(command_key, **kwargs)
153
+ @commands << Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
154
+ end
155
+
156
+ # A deterministic client-chosen placeholderId — the id-threading primitive shared with the
157
+ # diff synthesizer. Prefix marks the node kind for readability of the emitted batch.
158
+ def placeholder(prefix)
159
+ @counter += 1
160
+ "ph_#{prefix}_#{@counter}"
161
+ end
162
+
163
+ def symbolize(value)
164
+ case value
165
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
166
+ when Array then value.map { |e| symbolize(e) }
167
+ else value
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -1,16 +1,17 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Builder
5
- end
6
- end
7
- end
8
- end
9
-
10
- require_relative 'builder/location_structure'
11
- require_relative 'builder/action'
12
- require_relative 'builder/contractor_entity'
13
- require_relative 'builder/register'
14
- require_relative 'builder/page'
15
- require_relative 'builder/kickstand'
16
- require_relative 'builder/template'
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Builder
5
+ end
6
+ end
7
+ end
8
+ end
9
+
10
+ require_relative 'builder/location_structure'
11
+ require_relative 'builder/action'
12
+ require_relative 'builder/contractor_entity'
13
+ require_relative 'builder/register'
14
+ require_relative 'builder/page'
15
+ require_relative 'builder/kickstand'
16
+ require_relative 'builder/template'
17
+ require_relative 'builder/template_builder'
@@ -9,9 +9,12 @@ module Ecoportal
9
9
  # callers may also pass already-translated hashes or SearchConf instances directly.
10
10
  #
11
11
  # v2 → GraphQL translation table:
12
- # date_filter lbound/ubound → gte/lte (ES date range)
12
+ # date_filter lbound/ubound → gte/lte; Date FIELD (key 'date.zXXXX')
13
+ # range_date_filter + membranes.date path; system date → date_filter
13
14
  # text_filter {query, key} → match_filter {key, value} (partial text)
14
- # options_filter {chosen, key, mode}one_of_filter {key, values} (or none_of_filter)
15
+ # options_filter Select FIELD (key 'select_str.zXXXX')has_any_filter
16
+ # {key, path, field_key: 'selected', values} (doesnt_have_any_filter to
17
+ # exclude); system option key → one_of_filter/none_of_filter {key, values}
15
18
  # tag_filter passed through (same ES backend, same format)
16
19
  # register_filter passed through
17
20
  # and_filter / or_filter passed through
@@ -43,12 +46,18 @@ module Ecoportal
43
46
  # exact one: it matches the field's `.exact` keyword subfield (backend
44
47
  # NewEp::Es::Filters::Pages::IsFilter → ExactFilter on `membranes.<type>.exact`),
45
48
  # whereas match_filter/contains_filter hit the analyzed `.value` and miss exact SKUs.
46
- # Requires `path` (membranes.<type>) derived from the key prefix when not given.
49
+ # A register FIELD key ('plain_text.zXXXX') needs the nested membranes.<type> path;
50
+ # a SYSTEM key (external_id/id/state/creator_id/source) must NOT carry a path — the
51
+ # backend routes it to Global::ExactFilter which joins [@path,@key] into the term key,
52
+ # so a spurious 'membranes.external_id' yields term 'membranes.external_id.external_id'
53
+ # → 0 results (proof: pages/is_filter.rb:36-55 passes path: @path to Global::ExactFilter;
54
+ # global/exact_filter.rb:18 builds the term as [@path, @key].compact.join(".")).
47
55
  # CAVEAT: case-insensitive and only the first 64 chars are significant (values are
48
56
  # lowercased + truncated at index time). No case-sensitive/>64-char exact exists.
49
57
  def translate_exact_filter(filter)
50
58
  key = filter['key'] || filter[:key]
51
- path = filter['path'] || filter[:path] || membrane_path_for(key)
59
+ path = filter['path'] || filter[:path]
60
+ path ||= membrane_path_for(key) if membrane_field?(key)
52
61
  value = filter['value'] || filter[:value] || filter['query'] || filter[:query]
53
62
  { operation: 'is_filter', params: { key: key, path: path, value: value }.compact }
54
63
  end
@@ -59,6 +68,14 @@ module Ecoportal
59
68
  prefix.empty? ? nil : "membranes.#{prefix}"
60
69
  end
61
70
 
71
+ # A register FIELD key carries a `<type>.<hash>` shape (e.g. 'date.zab1bddc3',
72
+ # 'select_str.z223beb6d'); system/top-level keys (created_at, id, external_id) have
73
+ # no field-type prefix. Field filters need the nested membranes.<type> path (and,
74
+ # for options, a field_key), which system filters do not.
75
+ def membrane_field?(key)
76
+ key.to_s.include?('.')
77
+ end
78
+
62
79
  # v2 text_filter {query, key} → GraphQL match_filter (partial/full-text on key).
63
80
  def translate_text_filter(filter)
64
81
  {
@@ -70,34 +87,52 @@ module Ecoportal
70
87
  }
71
88
  end
72
89
 
73
- # v2 options_filter {chosen, key, mode} → one_of_filter (match any chosen) or
74
- # none_of_filter (exclude) both take {key, values}.
90
+ # v2 options_filter {chosen, key, mode} → GraphQL.
91
+ # A register Select FIELD (key like 'select_str.zXXXX') filters via
92
+ # has_any_filter / doesnt_have_any_filter over its nested path
93
+ # (membranes.select_str) with field_key 'selected' and the chosen option ids as
94
+ # `values` (backend NewEp::Es::Filters::Pages::*::HasAnyFilter). A system/top-level
95
+ # option key (e.g. 'id') keeps one_of_filter / none_of_filter with {key, values}.
75
96
  def translate_options_filter(filter)
76
- mode = (filter['mode'] || filter[:mode]).to_s.downcase
77
- op = %w[none exclude not not_in].include?(mode) ? 'none_of_filter' : 'one_of_filter'
78
- {
79
- operation: op,
80
- params: {
81
- key: filter['key'] || filter[:key],
82
- values: Array(filter['chosen'] || filter[:chosen])
83
- }
84
- }
97
+ key = filter['key'] || filter[:key]
98
+ values = Array(filter['chosen'] || filter[:chosen])
99
+ exclude = exclude_mode?(filter)
100
+
101
+ unless membrane_field?(key)
102
+ op = exclude ? 'none_of_filter' : 'one_of_filter'
103
+ return { operation: op, params: { key: key, values: values } }
104
+ end
105
+
106
+ op = exclude ? 'doesnt_have_any_filter' : 'has_any_filter'
107
+ path = filter['path'] || filter[:path] || membrane_path_for(key)
108
+ field_key = filter['field_key'] || filter[:field_key] || 'selected'
109
+ { operation: op, params: { key: key, path: path, field_key: field_key, values: values } }
85
110
  end
86
111
 
112
+ # v2 date_filter → GraphQL. A register Date FIELD (key like 'date.zXXXX') filters
113
+ # via range_date_filter on its nested path (membranes.date); a system/top-level date
114
+ # (created_at/updated_at) uses date_filter. Both share range params (gte/lte/time_zone)
115
+ # — range_date_filter additionally carries `path` (backend BaseRangeDateFilter < DateFilter).
87
116
  def translate_date_filter(filter)
88
- key = filter['key'] || filter[:key]
89
- {
90
- operation: 'date_filter',
91
- params: {
92
- key: key,
93
- time_zone: filter['time_zone'] || filter[:time_zone] || 'UTC'
94
- }.tap do |params|
95
- lbound = filter['lbound'] || filter[:lbound]
96
- ubound = filter['ubound'] || filter[:ubound]
97
- params[:gte] = lbound if lbound
98
- params[:lte] = ubound if ubound
99
- end
100
- }
117
+ key = filter['key'] || filter[:key]
118
+ field = membrane_field?(key)
119
+ path = filter['path'] || filter[:path]
120
+ path ||= membrane_path_for(key) if field
121
+
122
+ params = { key: key, time_zone: filter['time_zone'] || filter[:time_zone] || 'UTC' }
123
+ params[:path] = path if path
124
+ lbound = filter['lbound'] || filter[:lbound]
125
+ ubound = filter['ubound'] || filter[:ubound]
126
+ params[:gte] = lbound if lbound
127
+ params[:lte] = ubound if ubound
128
+
129
+ { operation: field ? 'range_date_filter' : 'date_filter', params: params }
130
+ end
131
+
132
+ # "exclude" style modes map to the negative filter operation.
133
+ def exclude_mode?(filter)
134
+ mode = (filter['mode'] || filter[:mode]).to_s.downcase
135
+ %w[none exclude not not_in].include?(mode)
101
136
  end
102
137
  end
103
138
  end