ecoportal-api-graphql 1.3.10 → 1.3.11

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/diff_pairing_engine.md +243 -0
  3. data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
  4. data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
  5. data/.ai-assistance/code/workflow-command-guide.md +28 -0
  6. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
  7. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
  8. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
  9. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
  10. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
  11. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
  12. data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
  13. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +77 -0
  14. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +97 -0
  15. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
  16. data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
  17. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
  18. data/.ai-assistance/projects/template-maintenance/README.md +14 -0
  19. data/CHANGELOG.md +87 -0
  20. data/docs/worklog.md +279 -0
  21. data/ecoportal-api-graphql.gemspec +1 -1
  22. data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
  23. data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
  24. data/lib/ecoportal/api/graphql/builder.rb +17 -16
  25. data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
  26. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
  27. data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
  28. data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
  29. data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
  30. data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
  31. data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
  32. data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
  33. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
  34. data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
  35. data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
  36. data/lib/ecoportal/api/graphql/diff.rb +34 -0
  37. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
  38. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
  39. data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
  40. data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
  41. data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
  42. data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
  43. data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
  44. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
  45. data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
  46. data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
  47. data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
  48. data/lib/ecoportal/api/graphql.rb +1 -0
  49. data/lib/ecoportal/api/graphql_version.rb +1 -1
  50. data/tests/dump_template_model.rb +90 -0
  51. data/tests/validate_queries.rb +31 -9
  52. metadata +31 -3
@@ -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'
@@ -0,0 +1,59 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # One structural change between two snapshots of the same page/template.
6
+ #
7
+ # Deliberately command-ready: op + kind + id + before/after is enough for a later pass to
8
+ # synthesise the matching WorkflowCommand (editStage / addField / removeField /
9
+ # editFieldConfiguration / addSelectFieldOption / …) — the "portable commit".
10
+ #
11
+ # op: :added | :removed | :changed | :moved
12
+ # kind: :stage | :section | :field | :option | :field_config | :gauge_stop
13
+ #
14
+ # parent_id: id of the owning entity when the change is nested and the child id alone is
15
+ # not enough to address a command. Set for :option changes (the parent data field id) so a
16
+ # synthesiser can emit add/edit/removeSelectFieldOption without re-deriving it; also for
17
+ # :field_config (the data field id) and :gauge_stop (the gauge field id).
18
+ #
19
+ # by_type: for :field_config changes, the `byType` sub-hash key (e.g. :gauge, :select, :date)
20
+ # under which the typed editFieldConfiguration property is nested. nil for other kinds.
21
+ #
22
+ # owner_id: the id of the GRANDPARENT node, when a create needs a second structural back-ref
23
+ # that parent_id alone cannot carry. Set for an ADDED :field (parent_id = its section id,
24
+ # owner_id = its stage id) because addField requires BOTH sectionId and stageId (both
25
+ # NON_NULL in the schema). nil for every other kind/op.
26
+ Change = Struct.new(:op, :kind, :id, :label, :path, :attribute, :before, :after, :parent_id,
27
+ :by_type, :owner_id, keyword_init: true) do
28
+ def to_h
29
+ {
30
+ op: op, kind: kind, id: id, label: label, path: path,
31
+ attribute: attribute, before: before, after: after, parent_id: parent_id,
32
+ by_type: by_type, owner_id: owner_id
33
+ }.compact
34
+ end
35
+
36
+ # One-line human description for a changelog / ticket comment.
37
+ def description
38
+ case op
39
+ when :added then "+ #{kind} '#{label}' added#{at_path}"
40
+ when :removed then "- #{kind} '#{label}' removed#{at_path}"
41
+ when :changed then "~ #{kind} '#{label}' #{attribute}: #{fmt(before)} -> #{fmt(after)}#{at_path}"
42
+ when :moved then "> #{kind} '#{label}' moved: #{fmt(before)} -> #{fmt(after)}"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def at_path
49
+ path && !path.empty? ? " (#{path})" : ''
50
+ end
51
+
52
+ def fmt(value)
53
+ value.nil? ? 'nil' : value.inspect
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,329 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # Turns a set of `Diff::Change` objects (from `VersionDiff`) into an ordered batch of built
6
+ # `WorkflowCommand` hashes — the replayable "commit".
7
+ #
8
+ # changes = VersionDiff.new(v1, v2).changes
9
+ # synth = CommandSynthesizer.new(changes)
10
+ # synth.commands # => [{ editFieldConfiguration: {...} }, { removeField: {...} }, ...]
11
+ # synth.unsupported # => [Change, ...] (change kinds/ops with no matching command)
12
+ #
13
+ # SCOPE — self-version diff by default. The ids carried by each Change are REAL server ids of
14
+ # the SAME object across two of its own versions, so they can be replayed as-is. For a
15
+ # cross-object diff (UAT<->PROD, page<->template) every id in the emitted commands must first
16
+ # be translated through a pairing map before replay.
17
+ #
18
+ # RESOLVER — some edits address a TARGET node by an id the Change does not carry (a field
19
+ # move needs the destination *section id*; a section move needs the destination *stage id*).
20
+ # `VersionDiff` records those targets by their human key (section heading, stage name) because
21
+ # that is all a structural diff knows. Pass a `resolver` that maps
22
+ # `resolve(kind, key) => id` (e.g. the pairing engine's ledger, or a lookup built from the
23
+ # target doc) and those moves become supported. WITHOUT a resolver they stay UNSUPPORTED —
24
+ # the synthesiser never guesses a target id.
25
+ #
26
+ # ORDERING — structure before children, and within a kind: removes, then adds, then moves,
27
+ # then edits. Structural creates (stages, sections, fields) precede the option-level edits
28
+ # that depend on them, so a straight left-to-right replay stays valid.
29
+ #
30
+ # SAFETY — a change with no faithful command is NEVER guessed. It is collected into
31
+ # `unsupported` for the caller to inspect. In particular a field *type* change has no edit
32
+ # command in the schema (there is no editFieldType), so it is reported as unsupported rather
33
+ # than silently rebuilt (a rebuild would drop the field's data and history).
34
+ class CommandSynthesizer
35
+ # Emit order: structure first (stages -> sections -> fields), then options; and within a
36
+ # kind, removes -> adds -> moves -> edits, so a straight left-to-right replay stays valid.
37
+ KIND_ORDER = { stage: 0, section: 1, field: 2, field_config: 3, gauge_stop: 4, option: 5 }.freeze
38
+ OP_ORDER = { removed: 0, added: 1, moved: 2, changed: 3 }.freeze
39
+
40
+ # @param changes [Array<Change>]
41
+ # @param resolver [#resolve, nil] maps (kind, human-key) -> target id for move commands.
42
+ # Any object answering `resolve(kind, key)` (returning an id or nil). When nil, moves
43
+ # that need a target id are reported as unsupported.
44
+ # @param thread_placeholders [Boolean] when true, structural creates (addStage/addSection/
45
+ # addField) emit a client-chosen `placeholderId`, and any later command in the SAME batch
46
+ # that references a node created here substitutes that placeholder for the (target-invalid)
47
+ # source id. This keeps add-then-reference sequences self-consistent within one
48
+ # executeWorkflowCommands call. Off by default (self-version replay uses the real ids).
49
+ def initialize(changes, resolver: nil, thread_placeholders: false)
50
+ @changes = Array(changes)
51
+ @resolver = resolver
52
+ @thread_placeholders = thread_placeholders
53
+ @unsupported = []
54
+ end
55
+
56
+ # Ordered Array of built command hashes (each `{ commandKey => {..input..} }`).
57
+ def commands
58
+ @commands ||= build_all
59
+ end
60
+
61
+ # Changes that could not be mapped to a command. Populated as a side effect of `commands`.
62
+ def unsupported
63
+ commands
64
+ @unsupported
65
+ end
66
+
67
+ private
68
+
69
+ # Group by (kind, op) and emit in a dependency-safe order:
70
+ # stages -> sections -> fields -> options, and remove -> add -> move -> change within each.
71
+ def build_all
72
+ build_placeholder_map
73
+ ordered = @changes.sort_by { |c| [KIND_ORDER.fetch(c.kind, 99), OP_ORDER.fetch(c.op, 99)] }
74
+ # command_for may return a single built hash, an Array of them (a move that emits
75
+ # remove+add), or nil (unsupported). Normalise each to a list, flatten, drop nils.
76
+ ordered.flat_map { |change| wrap(command_for(change)) }
77
+ end
78
+
79
+ # When threading is on, assign a deterministic placeholderId to every ADDED structural
80
+ # node (stage/section/field), keyed by its source id. A later command in this batch that
81
+ # references such an id is rewritten to the placeholder (the source id is not valid on the
82
+ # target). Threading OFF -> empty map -> `ref`/`placeholder_for` are no-ops.
83
+ def build_placeholder_map
84
+ @placeholders = {}
85
+ return unless @thread_placeholders
86
+
87
+ prefixes = { stage: 'stg', section: 'sec', field: 'fld' }
88
+ @changes.select { |c| c.op == :added && prefixes.key?(c.kind) }.each_with_index do |change, i|
89
+ next if change.id.nil?
90
+
91
+ @placeholders[change.id] = "ph_#{prefixes[change.kind]}_#{i}"
92
+ end
93
+ end
94
+
95
+ # The placeholder token for a node created in THIS batch, or nil if it was not.
96
+ def placeholder_for(id)
97
+ @placeholders[id]
98
+ end
99
+
100
+ # Reference an id the way a sibling command must: the batch-local placeholder if the node
101
+ # is being created here, otherwise the id as-is (a pre-existing target node).
102
+ def ref(id)
103
+ placeholder_for(id) || id
104
+ end
105
+
106
+ def command_for(change)
107
+ case change.kind
108
+ when :stage then stage_command(change)
109
+ when :section then section_command(change)
110
+ when :field then field_command(change)
111
+ when :field_config then field_config_command(change)
112
+ when :gauge_stop then gauge_stop_command(change)
113
+ when :option then option_command(change)
114
+ else unsupported!(change)
115
+ end
116
+ end
117
+
118
+ # --- stage ---------------------------------------------------------
119
+
120
+ def stage_command(change)
121
+ case change.op
122
+ when :added then build(:addStage, name: change.label, placeholderId: placeholder_for(change.id))
123
+ when :removed then build(:removeStage, stageId: change.id)
124
+ when :moved then build(:moveStage, stageId: change.id, ordering: change.after)
125
+ when :changed
126
+ return build(:editStage, stageId: change.id, name: change.after) if change.attribute == 'name'
127
+
128
+ unsupported!(change)
129
+ else unsupported!(change)
130
+ end
131
+ end
132
+
133
+ # --- section -------------------------------------------------------
134
+
135
+ def section_command(change)
136
+ case change.op
137
+ when :added then add_section(change)
138
+ when :removed then build(:removeSection, sectionId: change.id)
139
+ when :moved then section_move(change)
140
+ when :changed
141
+ return build(:editSectionHeader, sectionId: change.id, header: change.after) if change.attribute == 'heading'
142
+
143
+ unsupported!(change)
144
+ else unsupported!(change)
145
+ end
146
+ end
147
+
148
+ # An added section is created with addSection (layout/placeholder), then WIRED to its
149
+ # parent stage with a follow-up addStageSection — addSection itself has no stage-id key in
150
+ # the schema, the stage<->section link is a separate command. The stage back-ref threads
151
+ # through `ref`: the parent stage's placeholder when it is created in this same batch, else
152
+ # its real id. When the parent stage id is unknown (a hand-assembled Change with no
153
+ # parent_id), only addSection is emitted — the historical behaviour, unchanged.
154
+ def add_section(change)
155
+ cmds = [build(:addSection, placeholderId: placeholder_for(change.id))]
156
+ cmds << build(:addStageSection, stageId: ref(change.parent_id), sectionId: ref(change.id)) unless change.parent_id.nil?
157
+ cmds
158
+ end
159
+
160
+ # A section 'move' is a stage reassignment: detach from the old stage, attach to the new.
161
+ # Both need a stage id; VersionDiff records only the stage NAME (before/after), so a
162
+ # resolver is required to turn those names into ids. Emitted as two commands so the replay
163
+ # is faithful (remove then add). Without a resolver -> unsupported (never guess a stage id).
164
+ def section_move(change)
165
+ return unsupported!(change) if change.attribute != 'stage'
166
+
167
+ from_id = resolve(:stage, change.before)
168
+ to_id = resolve(:stage, change.after)
169
+ return unsupported!(change) if to_id.nil?
170
+
171
+ cmds = []
172
+ cmds << build(:removeStageSection, stageId: from_id, sectionId: change.id) if from_id
173
+ cmds << build(:addStageSection, stageId: to_id, sectionId: change.id)
174
+ cmds
175
+ end
176
+
177
+ # --- field ---------------------------------------------------------
178
+
179
+ def field_command(change)
180
+ case change.op
181
+ when :added then add_field(change)
182
+ when :removed then build(:removeField, id: change.id)
183
+ when :moved then field_move(change)
184
+ when :changed then field_edit(change)
185
+ else unsupported!(change)
186
+ end
187
+ end
188
+
189
+ # An added field carries its structural back-refs: sectionId (parent_id) and stageId
190
+ # (owner_id) — both NON_NULL on the schema's addField input. Each threads through `ref`:
191
+ # the parent's placeholder when the parent stage/section is created in this same batch,
192
+ # else its real id. When a back-ref id is unknown (a hand-assembled Change without
193
+ # parent_id/owner_id) that key is simply omitted (AddField.build compacts nils) — the
194
+ # historical behaviour, unchanged.
195
+ def add_field(change)
196
+ build(:addField,
197
+ label: change.label,
198
+ sectionId: (ref(change.parent_id) unless change.parent_id.nil?),
199
+ stageId: (ref(change.owner_id) unless change.owner_id.nil?),
200
+ placeholderId: placeholder_for(change.id))
201
+ end
202
+
203
+ def field_edit(change)
204
+ return build(:editFieldConfiguration, data_field_id: change.id, label: change.after) if change.attribute == 'label'
205
+
206
+ # Field TYPE change: no editFieldType command exists in the schema. Do not synthesise a
207
+ # remove+add (it would destroy the field's data) — report it for manual handling.
208
+ unsupported!(change)
209
+ end
210
+
211
+ # A field 'move' is a section reassignment. moveField needs the destination SECTION id;
212
+ # VersionDiff records only the section HEADING (before/after). A resolver turns the target
213
+ # heading into a section id. Without one -> unsupported (never guess a section id).
214
+ def field_move(change)
215
+ return unsupported!(change) if change.attribute != 'section'
216
+
217
+ section_id = resolve(:section, change.after)
218
+ return unsupported!(change) if section_id.nil?
219
+
220
+ build(:moveField, id: change.id, sectionId: section_id)
221
+ end
222
+
223
+ # --- typed field configuration (byType) ----------------------------
224
+
225
+ # A per-type field-configuration change maps to editFieldConfiguration with the changed
226
+ # property nested under its `byType` sub-hash (e.g. { gauge: { max: 5.0 } }). VersionDiff
227
+ # stamps `by_type` (the sub-hash key) and `parent_id` (the data field id). Without a
228
+ # by_type key we cannot address the typed input -> unsupported (never guessed).
229
+ def field_config_command(change)
230
+ return unsupported!(change) unless change.op == :changed
231
+
232
+ field_id = change.parent_id || change.id
233
+ by_type = change.by_type
234
+ return unsupported!(change) if field_id.nil? || by_type.nil? || change.attribute.nil?
235
+
236
+ body = { by_type => { change.attribute.to_sym => change.after } }
237
+ build(:editFieldConfiguration, data_field_id: ref(field_id), byType: body)
238
+ end
239
+
240
+ # --- gauge stops ---------------------------------------------------
241
+
242
+ # Gauge stops address the parent gauge field id (parent_id). Add needs threshold+color
243
+ # (carried on change.after); edit/remove need the stop id (change.id).
244
+ def gauge_stop_command(change)
245
+ field_id = change.parent_id
246
+ return unsupported!(change) if field_id.nil?
247
+
248
+ field_ref = ref(field_id)
249
+ case change.op
250
+ when :added then add_gauge_stop(change, field_ref)
251
+ when :removed then build(:removeGaugeFieldStop, data_field_id: field_ref, stop_id: change.id)
252
+ when :changed then edit_gauge_stop(change, field_ref)
253
+ else unsupported!(change)
254
+ end
255
+ end
256
+
257
+ def add_gauge_stop(change, field_id)
258
+ spec = change.after
259
+ return unsupported!(change) unless spec.is_a?(Hash)
260
+
261
+ build(:addGaugeFieldStop, data_field_id: field_id, threshold: spec[:threshold], color: spec[:color])
262
+ end
263
+
264
+ def edit_gauge_stop(change, field_id)
265
+ case change.attribute
266
+ when 'threshold' then build(:editGaugeFieldStop, data_field_id: field_id, stop_id: change.id, threshold: change.after)
267
+ when 'color' then build(:editGaugeFieldStop, data_field_id: field_id, stop_id: change.id, color: change.after)
268
+ else unsupported!(change)
269
+ end
270
+ end
271
+
272
+ # --- option --------------------------------------------------------
273
+
274
+ def option_command(change)
275
+ field_id = change.parent_id
276
+ return unsupported!(change) if field_id.nil?
277
+
278
+ field_ref = ref(field_id)
279
+ case change.op
280
+ when :added then build(:addSelectFieldOption, data_field_id: field_ref, label: change.label)
281
+ when :removed then build(:removeSelectFieldOption, data_field_id: field_ref, option_id: change.id)
282
+ when :changed then edit_option(change, field_ref)
283
+ else unsupported!(change)
284
+ end
285
+ end
286
+
287
+ def edit_option(change, field_id)
288
+ case change.attribute
289
+ when 'label' then build(:editSelectFieldOption, data_field_id: field_id, option_id: change.id, label: change.after)
290
+ when 'weight' then build(:editSelectFieldOption, data_field_id: field_id, option_id: change.id, weight: change.after)
291
+ else unsupported!(change)
292
+ end
293
+ end
294
+
295
+ # --- helpers -------------------------------------------------------
296
+
297
+ def build(command_key, **kwargs)
298
+ Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
299
+ end
300
+
301
+ # Ask the resolver for the target id of `key` (a heading / stage name). Returns nil when
302
+ # there is no resolver or it cannot resolve — callers treat nil as "unsupported".
303
+ def resolve(kind, key)
304
+ return nil if key.nil? || @resolver.nil?
305
+
306
+ @resolver.resolve(kind, key)
307
+ end
308
+
309
+ # Normalise a command_for result (nil | Hash | Array<Hash>) into a flat list of hashes.
310
+ # A built command is a Hash, so we must NOT use Kernel#Array (it would splat a Hash into
311
+ # key/value pairs).
312
+ def wrap(result)
313
+ case result
314
+ when nil then []
315
+ when Array then result.compact
316
+ else [result]
317
+ end
318
+ end
319
+
320
+ # Record an unmappable change and return nil (filtered out of the command list).
321
+ def unsupported!(change)
322
+ @unsupported << change
323
+ nil
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end