eco-helpers 3.2.14 → 3.2.16

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/conventions/code-working-tree-protocol.md +176 -0
  3. data/.ai-assistance/scripts/token-logger.js +220 -0
  4. data/.ai-assistance/scripts/token-report.ts +158 -0
  5. data/.ai-assistance/scripts/token-session-start.js +66 -0
  6. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +417 -0
  7. data/.ai-assistance/skills/ruby-scripting/SKILL.md +215 -0
  8. data/.ai-assistance/standards-version.json +10 -0
  9. data/.ai-assistance/token-budget.json +39 -0
  10. data/.claude/settings.json +103 -0
  11. data/.gitignore +2 -0
  12. data/CHANGELOG.md +17 -0
  13. data/CLAUDE.md +83 -0
  14. data/eco-helpers.gemspec +1 -1
  15. data/lib/eco/api/usecases/CLAUDE.md +78 -0
  16. data/lib/eco/api/usecases/default/pages.rb +30 -0
  17. data/lib/eco/api/usecases/graphql/CLAUDE.md +120 -0
  18. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
  19. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
  20. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
  21. data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +234 -0
  22. data/lib/eco/api/usecases/graphql/compat.rb +6 -0
  23. data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +79 -0
  24. data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +76 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +59 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages.rb +7 -0
  33. data/lib/eco/api/usecases/graphql/samples.rb +1 -0
  34. data/lib/eco/api/usecases/graphql.rb +1 -0
  35. data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
  36. data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +7 -1
  37. data/lib/eco/version.rb +1 -1
  38. metadata +31 -3
@@ -0,0 +1,241 @@
1
+ module Eco::API::UseCases::GraphQL::Compat::OozeRedirect
2
+ # Applies one-time compatibility patches to v2 and GraphQL field classes.
3
+ # Idempotent — safe to call multiple times (first call does the work, rest no-op).
4
+ #
5
+ # After apply!:
6
+ # 1. V2 type classes recognise GraphQL instances in case/when dispatch
7
+ # 2. GraphQL field classes respond to the v2 field interface
8
+ module FieldPatches
9
+ @applied = false
10
+
11
+ V2_TO_GQL = {
12
+ 'PlainTextField' => 'PlainText',
13
+ 'NumberField' => 'Number',
14
+ 'SelectionField' => 'Select',
15
+ 'PeopleField' => 'People',
16
+ 'ReferenceField' => 'CrossReference'
17
+ }.freeze
18
+
19
+ def self.apply!
20
+ return if @applied
21
+
22
+ # eco-helpers lazy-requires the gem only when session.api(version: :graphql) is first
23
+ # used (runtime). apply! runs at OozeRedirect.included (boot) — BEFORE that — so the
24
+ # gem classes aren't loaded yet: every safe_const below would return nil, the patches
25
+ # would silently no-op, and @applied would latch with nothing patched. Force the gem
26
+ # load now so all the GraphQL consts resolve.
27
+ require 'ecoportal/api-graphql'
28
+
29
+ @applied = true
30
+ patch_v2_type_dispatch!
31
+ patch_graphql_base_field!
32
+ patch_graphql_people!
33
+ patch_graphql_cross_reference!
34
+ patch_graphql_select!
35
+ end
36
+
37
+ # --- 1. V2 type dispatch -------------------------------------------------
38
+
39
+ # Reopen each V2 field class so that `===` also returns true for the
40
+ # equivalent GraphQL field type. This makes existing `case/when` dispatch
41
+ # work transparently without touching the end scripts.
42
+ def self.patch_v2_type_dispatch!
43
+ V2_TO_GQL.each do |v2_type, gql_type|
44
+ v2_klass = safe_const("Ecoportal::API::V2::Page::Component::#{v2_type}")
45
+ gql_klass = safe_const("Ecoportal::API::GraphQL::Base::Page::DataField::#{gql_type}")
46
+ next unless v2_klass && gql_klass
47
+
48
+ gql_ref = gql_klass # capture for the closures
49
+ v2_ref = v2_klass
50
+
51
+ # case/when dispatch: `V2Class === graphql_instance`.
52
+ v2_klass.define_singleton_method(:===) do |obj|
53
+ super(obj) || obj.is_a?(gql_ref)
54
+ end
55
+
56
+ # is_a?/kind_of? guards: `graphql_instance.is_a?(V2Class)` — scripts use both the
57
+ # case/when form AND explicit is_a? guards (e.g. cans update_select). === alone
58
+ # doesn't cover is_a?, so make the GraphQL field recognise its v2 counterpart too.
59
+ gql_klass.prepend(Module.new do
60
+ define_method(:is_a?) { |klass| klass == v2_ref || super(klass) }
61
+ define_method(:kind_of?) { |klass| klass == v2_ref || super(klass) }
62
+ end)
63
+ end
64
+ end
65
+
66
+ # --- 2. GraphQL base field: submit! + ooze stub --------------------------
67
+
68
+ def self.patch_graphql_base_field!
69
+ base = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField')
70
+ return unless base
71
+
72
+ # submit! — stores a pending submit flag consumed by the infrastructure
73
+ base.class_eval do
74
+ def _compat_submit_pending?
75
+ @_v2_compat_submit_pending
76
+ end
77
+ end
78
+
79
+ # BasePage submit! — called as target.submit!(force: true) in scripts
80
+ page_base = safe_const('Ecoportal::API::GraphQL::Interface::BasePage')
81
+ return unless page_base
82
+
83
+ page_base.class_eval do
84
+ # v2-compat stage SUBMIT. Scripts call `target.submit!` (optionally pinning a
85
+ # stage via `submit!(stage_id:)`). Captures the intent; OozeRedirect#process_ooze
86
+ # consumes it → `graphql.pages.update(submit: true)`. Submitting completes the
87
+ # stage's fill-in task (the server requires all visible, non-hidden required
88
+ # fields to be filled). If the stage also has a review task configured, it then
89
+ # awaits a SEPARATE `sign_off!`. submit! does NOT sign off.
90
+ #
91
+ # `submit!(force: true)` maps to completePageTask.forcedComplete: true — the
92
+ # backend then SKIPS the empty visible-required-field check. This is admin/
93
+ # superuser-only server-side; the integration's service user must hold that
94
+ # permission (it did under APIv2, so the same account carries it on GraphQL).
95
+ def submit!(force: false, stage_id: nil)
96
+ @_v2_compat_submit_pending = true
97
+ @_v2_compat_submit_stage_id = stage_id
98
+ @_v2_compat_submit_force = force ? true : false
99
+ end
100
+
101
+ # v2-compat stage SIGN-OFF. Scripts call `target.sign_off!` (optionally
102
+ # `sign_off!(stage_id:)`). Per the platform model the stage tasks are sequential
103
+ # (fill-in → review): signing off a stage that has NOT been submitted yet also
104
+ # submits it — a permitted user may submit + sign off in one go (also the page-
105
+ # creation path). process_ooze realises this as
106
+ # `updatePage(submit: true, completePageTask { signOff: true })`, which the
107
+ # backend treats as submit + inline review approval, advancing the stage.
108
+ def sign_off!(stage_id: nil)
109
+ @_v2_compat_sign_off_pending = true
110
+ @_v2_compat_sign_off_stage_id = stage_id
111
+ end
112
+
113
+ def _compat_submit?
114
+ @_v2_compat_submit_pending
115
+ end
116
+
117
+ def _compat_submit_stage_id
118
+ @_v2_compat_submit_stage_id
119
+ end
120
+
121
+ def _compat_submit_force?
122
+ @_v2_compat_submit_force ? true : false
123
+ end
124
+
125
+ def _compat_sign_off?
126
+ @_v2_compat_sign_off_pending
127
+ end
128
+
129
+ def _compat_sign_off_stage_id
130
+ @_v2_compat_sign_off_stage_id
131
+ end
132
+ end
133
+ end
134
+
135
+ # --- 3. People field -----------------------------------------------------
136
+
137
+ def self.patch_graphql_people!
138
+ klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::People')
139
+ return unless klass
140
+
141
+ klass.prepend(Module.new do
142
+ # Return a DirtyArray so fld.people_ids << value triggers dirty tracking
143
+ def people_ids
144
+ DirtyArray.new(self, Array(super))
145
+ end
146
+
147
+ # Stub: back-reference used in v2 warning messages
148
+ def ooze
149
+ Struct.new(:uid, :name, :id).new('(graphql)', label, id)
150
+ end
151
+ end)
152
+ end
153
+
154
+ # --- 4. CrossReference (v2 ReferenceField) field -------------------------
155
+
156
+ def self.patch_graphql_cross_reference!
157
+ klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::CrossReference')
158
+ return unless klass
159
+
160
+ klass.prepend(Module.new do
161
+ # v2 compat: fld.reference_ids → fld.page_ids
162
+ def reference_ids
163
+ page_ids || []
164
+ end
165
+
166
+ # v2 compat: fld.add(ep_id)
167
+ def add(ep_id)
168
+ return if ep_id.nil?
169
+
170
+ self.page_ids = (page_ids || []) + [ep_id] unless (page_ids || []).include?(ep_id)
171
+ end
172
+
173
+ # v2 compat: fld.clear
174
+ def clear
175
+ self.page_ids = []
176
+ end
177
+
178
+ def ooze
179
+ Struct.new(:uid, :name, :id).new('(graphql)', label, id)
180
+ end
181
+ end)
182
+ end
183
+
184
+ # --- 5. Select field -----------------------------------------------------
185
+
186
+ OptionStruct = Struct.new(:id, :name, :value, :selected)
187
+
188
+ def self.patch_graphql_select!
189
+ klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::Select')
190
+ return unless klass
191
+
192
+ klass.prepend(Module.new do
193
+ # v2 compat: fld.options → array of OptionStructs (respond to .name, .value)
194
+ def options
195
+ Array(doc['options'] || []).map do |opt|
196
+ FieldPatches::OptionStruct.new(opt['id'], opt['name'], opt['value'], opt['selected'])
197
+ end
198
+ end
199
+
200
+ # v2 compat: fld.select(value, by_name: false) or fld.select(name, by_name: true)
201
+ def select(val, by_name: false)
202
+ select_option(val.to_s)
203
+ self
204
+ end
205
+
206
+ # v2 compat: fld.deselect(value) — deselect one specific option
207
+ def deselect(val)
208
+ (doc['options'] || []).each do |opt|
209
+ target = val.to_s
210
+ if [opt['id'], opt['name'], opt['value']].map(&:to_s).include?(target)
211
+ opt['selected'] = false
212
+ end
213
+ end
214
+ # No explicit dirty call: mutating doc['options'] in place is picked up
215
+ # by DataField's LeafDiffService diff (same as native select_option /
216
+ # clear_selection). There is no mark_dirty! method on the field.
217
+ self
218
+ end
219
+
220
+ # v2 compat: fld.values — array of selected option values
221
+ def values
222
+ (doc['options'] || []).select { |opt| opt['selected'] }.map { |opt| opt['value'] }
223
+ end
224
+
225
+ def ooze
226
+ Struct.new(:uid, :name, :id).new('(graphql)', label, id)
227
+ end
228
+ end)
229
+ end
230
+
231
+ def self.safe_const(path)
232
+ path.split('::').reduce(Object) { |mod, c| mod.const_get(c) }
233
+ rescue NameError
234
+ nil
235
+ end
236
+
237
+ private_class_method :patch_v2_type_dispatch!, :patch_graphql_base_field!,
238
+ :patch_graphql_people!, :patch_graphql_cross_reference!,
239
+ :patch_graphql_select!, :safe_const
240
+ end
241
+ end
@@ -0,0 +1,73 @@
1
+ module Eco::API::UseCases::GraphQL::Compat::OozeRedirect
2
+ # Extends OozeRedirect with Force / binding support via the GraphQL
3
+ # executeWorkflowCommands mutation.
4
+ #
5
+ # Automatically included by OozeRedirect when the ForceFields fragment is
6
+ # available in the loaded gem version. No additional include is needed in
7
+ # end scripts — the same single include covers forces too:
8
+ #
9
+ # include Eco::API::UseCases::GraphQL::Compat::OozeRedirect
10
+ #
11
+ # ## What ForceCompat adds
12
+ #
13
+ # 1. Override `with_each_entry` to fetch pages via Query::PageWithForces,
14
+ # which includes forces in the GraphQL response.
15
+ # 2. Override `process_ooze` to call save_force_changes! after the script
16
+ # runs, executing any accumulated force commands via executeWorkflowCommands.
17
+ #
18
+ # ## Accumulated commands
19
+ #
20
+ # Scripts interact with forces exactly as in v2:
21
+ # force.custom_script = new_script # queues editForce
22
+ # force.bindings.add(field, name: 'n') # queues addBinding
23
+ # force.bindings.delete!(binding) # queues removeBinding
24
+ #
25
+ # After process_ooze returns, ForceCompat collects all pending commands from
26
+ # all forces on the page and submits them in one executeWorkflowCommands call.
27
+ module ForceCompat
28
+ module Infrastructure
29
+ # Fetch pages with forces included in the GraphQL response.
30
+ def with_each_entry
31
+ warn_once(:force_fetch, 'ForceCompat: fetching pages with forces (Query::PageWithForces)')
32
+ target_ids.each do |page_id|
33
+ @target = Ecoportal::API::GraphQL::Query::PageWithForces.new(graphql.client).query(id: page_id)
34
+ if @target
35
+ yield
36
+ else
37
+ log(:warn) { "[ForceCompat] Could not fetch page #{page_id}" }
38
+ end
39
+ end
40
+ end
41
+
42
+ # After the script's process_ooze runs, submit all accumulated force commands.
43
+ def process_ooze
44
+ super
45
+ save_force_changes!
46
+ end
47
+
48
+ private
49
+
50
+ def save_force_changes!
51
+ return unless target.respond_to?(:forces)
52
+
53
+ force_col = target.forces
54
+ return unless force_col.dirty?
55
+
56
+ commands = force_col.pending_commands
57
+ return if commands.empty?
58
+
59
+ warn_once(:force_save, "ForceCompat: executing #{commands.size} force command(s) via executeWorkflowCommands")
60
+ return if simulate?
61
+
62
+ response = graphql.page.execute_force_commands(
63
+ id: target.id,
64
+ patch_ver: target.patchVer.to_i,
65
+ commands: commands
66
+ )
67
+ if response.respond_to?(:error?) && response.error?
68
+ log(:error) { "[ForceCompat] executeWorkflowCommands failed for page #{target.id}: #{response.body}" }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,234 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ # Transparently redirects an OozeSamples-based use case from the APIv2 ooze
3
+ # path to the GraphQL compat layer. Include it in the case class and nothing
4
+ # else needs to change in the script.
5
+ #
6
+ # ## What it redirects
7
+ #
8
+ # - Page iteration: `with_each_entry` fetches via `graphql.pages.get`
9
+ # - `api_v2` / `apiv2`: returns the graphql compat client
10
+ # - `stage(name)`: resolves via `target.stages[name]` (GraphQL stage model)
11
+ # - `with_fields(ooz, label:, type:)`: reads from `ooz.components` (GraphQL)
12
+ # - `update_ooze(ooz)`: saves via `graphql.pages.update`
13
+ # - `target.submit!` / `target.sign_off!`: captured and forwarded to `graphql.pages.update`
14
+ #
15
+ # ## What it patches (one-time, guarded)
16
+ #
17
+ # - V2 type classes (`PlainTextField`, `SelectionField`, etc.) recognise
18
+ # GraphQL instances in `case/when` dispatch via `===`
19
+ # - GraphQL `People` field: `people_ids <<` triggers dirty tracking
20
+ # - GraphQL `CrossReference` field: `add`, `clear`, `reference_ids`
21
+ # - GraphQL `Select` field: `select`, `deselect`, `values`, `options`
22
+ # (options returns Struct objects with `.name` / `.value`)
23
+ # - All GraphQL fields: `ooze` stub returns a minimal object for warning messages
24
+ # - `Interface::BasePage`: `submit!(stage_id:)` queues a submit-only flag (completes
25
+ # the fill-in task); `sign_off!(stage_id:)` queues sign-off. Tasks are sequential
26
+ # (fill-in → review): sign-off on a not-yet-submitted stage submits + signs off in
27
+ # one go (completePageTask{ signOff: true }). `submit!(force: true)` maps to
28
+ # completePageTask{ forcedComplete: true } (admin/superuser; skips the required-field check).
29
+ #
30
+ # ## Per-run debug logging
31
+ #
32
+ # Each type of redirection logs once per run (not per page) at `:debug` level.
33
+ # Filter with `ECOPORTAL_LOG_LEVEL=debug` or the session logger config.
34
+ #
35
+ # ## Usage
36
+ #
37
+ # class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::OozeSamples::TargetOozesUpdateCase
38
+ # include Eco::API::UseCases::GraphQL::Compat::OozeRedirect
39
+ # # ... rest of class unchanged ...
40
+ # end
41
+ #
42
+ # ## Limitations
43
+ #
44
+ # - Force / binding operations (`target.forces`, `force.bindings`) are not
45
+ # available in GraphQL — those calls will raise NoMethodError.
46
+ # - Field property setters (`fld.label=`, `fld.required=`) are template
47
+ # mutations; they are not supported and will raise NoMethodError.
48
+ # - `page_result.membranes` (on PreviewPage search results) is not supported
49
+ # directly; use register search via `graphql.registers.search` instead.
50
+ #
51
+ # ## TODO: Force / binding support (blocked on GraphQL endpoint)
52
+ #
53
+ # Engineering is working on exposing "legacy forces" via the GraphQL API.
54
+ # Once that endpoint is available, extend this module with a `ForceCompat`
55
+ # sub-module (same include pattern — nothing changes in end scripts) covering:
56
+ #
57
+ # target.forces.get_by_name(name) → query forces by name on a page
58
+ # force.bindings.get_by_name(name) → field bindings on a force
59
+ # force.bindings.add(field, name:) → add a binding
60
+ # force.bindings.delete!(binding) → remove a binding
61
+ # force.custom_script → read the LISP script
62
+ # force.custom_script = new_script → write the LISP script
63
+ # force.script → raw script content (alias)
64
+ #
65
+ # Affected cases currently blocked (from multi_org_api survey, ~50% of all
66
+ # ooze cases):
67
+ # act-gov: 5 x 20240130_act_*_case, rearrage_page_sites_case
68
+ # briscoes: remove_induction_sections, 310524_Briscoes_Remove_Tasks
69
+ # chorus: 4 x audit_update cases
70
+ # hcc: update_enterprise_risk_case
71
+ # lic: update_life_cycle_force_case
72
+ # mitre10: rich_text_update, update_location_force, updating_template
73
+ # npdc: contractor_title_force, risk_titile_force, fix_title_syncing,
74
+ # reminder_date_fields, 10092024_NPDC_CP_Add_Force
75
+ # profile-group: int_training_review, 20231026_profile_wellness
76
+ # turners-growers: event_changes, inj_cost_calc, remove_line_force
77
+ # twg: hide_attached_risks, add_new_force
78
+ #
79
+ # Implementation sketch (to be built when the endpoint lands):
80
+ #
81
+ # module ForceCompat
82
+ # # Patch GraphQL BasePage to respond to .forces
83
+ # # Returns a proxy whose #get_by_name queries the GraphQL force endpoint
84
+ # # and returns ForceProxy objects wrapping the response.
85
+ # # ForceProxy exposes #bindings (BindingsProxy), #custom_script, #script.
86
+ # # BindingsProxy exposes #get_by_name, #add, #delete!.
87
+ # end
88
+ #
89
+ # OozeRedirect.include(ForceCompat) # or extend OozeRedirect::FieldPatches
90
+ module OozeRedirect
91
+ require_relative 'ooze_redirect/dirty_array'
92
+ require_relative 'ooze_redirect/field_patches'
93
+ require_relative 'ooze_redirect/force_compat'
94
+
95
+ def self.included(base)
96
+ FieldPatches.apply!
97
+ # Provide `graphql` (lazy, memoized session.api(version: :graphql)) regardless of
98
+ # the host's base class. OozeSamples cases (cans/toocs) don't include the GraphQL
99
+ # helpers that Custom::UseCase paths pick up, yet Infrastructure#api_v2 /
100
+ # #with_each_entry / #update_ooze all call `graphql` — without this they raise
101
+ # NameError: undefined `graphql`.
102
+ base.include(Eco::API::UseCases::GraphQL::Helpers::Base::GraphQLEnv)
103
+ # Prepend Infrastructure first so it sits below ForceCompat in the MRO.
104
+ # With Ruby's prepend, last-prepended wins — so ForceCompat::Infrastructure
105
+ # must be prepended AFTER Infrastructure to be first in the lookup chain:
106
+ # MRO: ForceCompat::Infrastructure → Infrastructure → base class
107
+ base.prepend(Infrastructure)
108
+ base.prepend(ForceCompat::Infrastructure) if force_support?
109
+ end
110
+
111
+ # Returns true when the installed ecoportal-api-graphql gem exposes
112
+ # Query::PageWithForces (the forces-aware page query).
113
+ # Forces are NOT usable on GraphQL yet: Query::PageWithForces is a WIP whose query
114
+ # currently fails schema validation (selections on PageUnion; `id` on DataFieldBinding/
115
+ # SectionBinding; unused ForceFields) and the backend forces endpoint is still in
116
+ # progress. The class merely being *defined* is not a readiness signal — activating
117
+ # ForceCompat on that basis makes EVERY OozeRedirect case fetch via the broken force
118
+ # query (e.g. toocs, which doesn't even use forces). Keep OFF until forces actually
119
+ # work; then restore a real readiness check.
120
+ def self.force_support?
121
+ false
122
+ end
123
+
124
+ # Method overrides that replace the v2 infrastructure with GraphQL calls.
125
+ # Prepended onto the including class so they take priority over inherited methods.
126
+ module Infrastructure
127
+ # NOTE: with_each_entry / update_ooze / process_ooze are intentionally NOT overridden.
128
+ # The base OozeSamples loop (RegisterUpdateCase / TargetOozesUpdateCase) already does the
129
+ # right thing once api_v2 is redirected to GraphQL: it counts KPIs (search/retrieved/
130
+ # updated/created), dedups, queues, and — crucially — runs dry_run_feedback in simulate
131
+ # to PRINT the pending diff. Fetches go through ooze(id) → apiv2.pages.get (redirected
132
+ # below), and saves go through update_oozes → update_ooze → apiv2.pages.update. A captured
133
+ # target.submit!/sign_off! rides along on that single update via Input::Page::Update
134
+ # .from_model (it reads the page's _compat_* flags). Overriding the loop here is what
135
+ # silently dropped all of that — don't reintroduce it.
136
+
137
+ # --- API client redirect ------------------------------------------------
138
+
139
+ # Return the GraphQL compat client in place of the v2 client.
140
+ # Handles direct api_v2.pages.* calls in scripts that don't use OozeSamples
141
+ # iteration (e.g. get_new, create, update called directly).
142
+ def api_v2
143
+ warn_once(:api_v2, 'api_v2 → graphql compat layer')
144
+ graphql
145
+ end
146
+
147
+ alias apiv2 api_v2
148
+
149
+ # --- Stage access -------------------------------------------------------
150
+
151
+ # Resolve a stage by name from the current GraphQL page.
152
+ # Replaces the v2 stage fetch from OozeBaseCase.
153
+ def stage(id_name, ooze: target)
154
+ warn_once(:stage, "stage('#{id_name}') → StageView over target.stages[name]")
155
+ stg = ooze.stages[id_name.to_s]
156
+ return nil unless stg
157
+
158
+ # Return a StageView (v2 "page-at-a-stage" semantics): #id / #name / #state /
159
+ # #submit! / #as_update / #dirty? delegate to the PAGE, while #components and
160
+ # #sections are scoped to this stage. Scripts rely on stage.id == page.id
161
+ # (e.g. with_row(stage.id), keyed by page id); the raw Base::Page::Phased::Stage#id
162
+ # is the STAGE's own id and breaks that. Field objects stay the page's canonical
163
+ # ones, so mutations via stage.components still reach pages.update.
164
+ Ecoportal::API::GraphQL::Compat::StageView.new(ooze, stg.id)
165
+ rescue NoMethodError
166
+ log(:warn) { "[OozeRedirect] stages not available on #{ooze.class}" }
167
+ nil
168
+ end
169
+
170
+ # --- Field access -------------------------------------------------------
171
+
172
+ # Redirect field lookups from v2 component access to GraphQL components.
173
+ # Supports the same type: and label: keyword arguments.
174
+ def with_fields(ooz = target, type: nil, label: nil) # rubocop:disable Metrics/MethodLength
175
+ warn_once(:with_fields, 'with_fields → ooz.components (GraphQL)')
176
+ collection = ooz.respond_to?(:components) ? ooz.components : nil
177
+
178
+ unless collection
179
+ log(:warn) { "[OozeRedirect] No components on #{ooz.class}" }
180
+ return []
181
+ end
182
+
183
+ if label && type
184
+ field = collection.get_by_name(label)
185
+ field && type_match?(field, type) ? [field] : []
186
+ elsif label
187
+ [collection.get_by_name(label)].compact
188
+ elsif type
189
+ collection.get_by_type(type)
190
+ else
191
+ collection.to_a
192
+ end
193
+ end
194
+
195
+ # --- Dirty check --------------------------------------------------------
196
+
197
+ # v2 dirty?(ooze) reads patch_doc(ooze)['page']; the GraphQL compat get_body wraps the
198
+ # model's as_update under 'page' (nil when unchanged), so the base dirty? works too —
199
+ # but delegate to the model's real #dirty? for clarity AND treat a captured submit!/
200
+ # sign_off! as dirty, so the base update_ooze still fires for a page that only needs
201
+ # submitting (no field changes). The submit/sign-off itself is carried on that single
202
+ # update by Input::Page::Update.from_model (reads the _compat_* flags).
203
+ def dirty?(object)
204
+ return false unless object
205
+
206
+ pending = (object.respond_to?(:_compat_submit?) && object._compat_submit?) ||
207
+ (object.respond_to?(:_compat_sign_off?) && object._compat_sign_off?)
208
+ return true if pending
209
+ return object.dirty? if object.respond_to?(:dirty?)
210
+
211
+ super
212
+ end
213
+
214
+ private
215
+
216
+ # Log a debug message exactly once per use-case run per operation type.
217
+ def warn_once(op_key, message)
218
+ @_warned_ops ||= Set.new
219
+ return if @_warned_ops.include?(op_key)
220
+
221
+ @_warned_ops << op_key
222
+ log(:debug) { "[OozeRedirect] #{message}" }
223
+ end
224
+
225
+ # Check whether a GraphQL field matches a v2-style type string.
226
+ # v2 uses e.g. "select", "people", "cross_reference"; normalise both sides.
227
+ def type_match?(field, type_key)
228
+ target_norm = type_key.to_s.downcase.delete('_')
229
+ field_norm = field.type.to_s.downcase.delete('_')
230
+ target_norm == field_norm
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,6 @@
1
+ module Eco::API::UseCases::GraphQL
2
+ module Compat
3
+ end
4
+ end
5
+
6
+ require_relative 'compat/ooze_redirect'
@@ -0,0 +1,79 @@
1
+ # usecases/graphql/helpers
2
+
3
+ Mixin modules providing domain-specific helper methods for GraphQL use cases.
4
+ All modules are ultimately included via `Helpers::Base` into `GraphQL::Base`.
5
+
6
+ ---
7
+
8
+ ## Include chain
9
+
10
+ ```
11
+ GraphQL::Base
12
+ includes Helpers::Base
13
+ includes CaseEnv → session, options, config, simulate?, log, ErrorHandling
14
+ includes GraphQLEnv → graphql (lazy, memoized)
15
+ includes Helpers (loader)
16
+ includes Helpers::Location → location tree helpers
17
+ includes Helpers::Contractors → contractor entity helpers
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Helpers::Base (`helpers/base.rb`)
23
+
24
+ Core environment — included in every GraphQL use case.
25
+
26
+ | Method | Source | Description |
27
+ |--------|--------|-------------|
28
+ | `session` | `CaseEnv` | Current `Eco::API::Session` |
29
+ | `options` | `CaseEnv` | Options hash from CLI/runner |
30
+ | `config` | `CaseEnv` | `session.config` shortcut |
31
+ | `simulate?` | `CaseEnv` | `options[:simulate] \|\| options[:dry_run]` |
32
+ | `log(level)` | `CaseEnv` | Logger proxy |
33
+ | `graphql` | `GraphQLEnv` | Lazy-loaded `Ecoportal::API::GraphQL` instance |
34
+ | `backup(data, type:)` | `Helpers::Base` | Save JSON to requests folder |
35
+ | `exit_error(msg)` | `Helpers::Base` | Log error and `exit(1)` |
36
+
37
+ ---
38
+
39
+ ## Helpers::Location (`helpers/location/`)
40
+
41
+ Location tree access, tag remapping, classification parsing.
42
+
43
+ - `helpers/location/base.rb` — `Location::Base` mixin
44
+ - `helpers/location/base/tree_tracking.rb` — track tree mutations
45
+ - `helpers/location/base/classifications_parser.rb` — parse location classifications
46
+ - `helpers/location/tags_remap/` — remapping tags across location changes
47
+ - `helpers/location/command/` — apply/diff location structure commands
48
+
49
+ ---
50
+
51
+ ## Helpers::Contractors (`helpers/contractors/`)
52
+
53
+ Contractor entity loading helpers.
54
+
55
+ - `helpers/contractors/base.rb` — base contractor helpers
56
+ - `helpers/contractors/base/load.rb` — batch load contractor entities
57
+
58
+ ---
59
+
60
+ ## Adding a new helper
61
+
62
+ 1. Create `helpers/my_domain/base.rb`:
63
+ ```ruby
64
+ module Eco::API::UseCases::GraphQL::Helpers
65
+ module MyDomain
66
+ module Base
67
+ private
68
+ def my_helper_method
69
+ graphql.myDomainQuery(...)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ ```
75
+ 2. Create `helpers/my_domain.rb` as a loader that includes `Base`
76
+ 3. Add `require_relative 'my_domain'` to `helpers.rb`
77
+
78
+ The helper is then available in all cases that include `Helpers::Base` (i.e., all
79
+ subclasses of `GraphQL::Base` including `PageCase` and `OrgPageCase`).